polats commited on
Commit
d747e0a
·
verified ·
1 Parent(s): 377fc1e

Deploy Karate Wiener (kimodo kata maker)

Browse files
Files changed (4) hide show
  1. app.py +27 -14
  2. docs/comic_pipeline_plan.md +91 -0
  3. docs/premises_sample.json +125 -0
  4. stance_features.py +162 -0
app.py CHANGED
@@ -4700,7 +4700,7 @@ _COMPOSE_JS = r"""
4700
  if (byEl) {
4701
  const k = composeCurrentKata; const bits = [];
4702
  if (!k.local && k.contributor) bits.push('by ' + k.contributor);
4703
- if (k.forked_from) bits.push('⑂ ' + (k.forked_from.contributor_name || k.forked_from.contributor || 'forked'));
4704
  byEl.textContent = bits.join(' · ');
4705
  }
4706
  box.classList.add('show');
@@ -4792,7 +4792,10 @@ _COMPOSE_JS = r"""
4792
  // count all descendants (not just the spine) + 1 for the root
4793
  const all = {}; const stack = [rid];
4794
  while (stack.length) { const x = stack.pop(); (children[x] || []).forEach((c) => { if (!all[c]) { all[c] = 1; stack.push(c); } }); }
4795
- const recipe = m.compose_recipe || null;
 
 
 
4796
  let label = recipe && recipe.length ? recipeLabel(recipe) : (m.prompt || rid);
4797
  // Attribution: the root's creator name, else the first spine move that has one
4798
  // (older roots were built anonymously but their later moves may carry a name).
@@ -4853,7 +4856,7 @@ _COMPOSE_JS = r"""
4853
  // Attribution: who made it (Community), and fork lineage if any.
4854
  const meta = document.createElement('div'); meta.className = 'kimodo-cz-katameta';
4855
  if (!k.local) { const by = document.createElement('div'); by.className = 'kimodo-cz-kataby'; by.textContent = 'by ' + (k.contributor || 'anonymous'); meta.appendChild(by); }
4856
- if (k.forked_from) { const ff = document.createElement('div'); ff.className = 'kimodo-cz-katafrom'; ff.textContent = '⑂ forked from ' + (k.forked_from.contributor_name || k.forked_from.contributor || 'unknown'); meta.appendChild(ff); }
4857
  // filmstrip: the recipe's stance poses (start/end accented), else the spine clips
4858
  const film = document.createElement('div'); film.className = 'kimodo-cz-film';
4859
  const frames = k.recipe && k.recipe.length ? k.recipe.map((r) => r.id) : k.ids;
@@ -5029,14 +5032,23 @@ _COMPOSE_JS = r"""
5029
  // Fork a community kata into My Katas: copy its recipe + spine (clips are reused so it
5030
  // plays instantly), keep attribution to the original author, and open it editable.
5031
  function forkKata(k) {
5032
- if (!k || !k.recipe || !k.recipe.length) { setComposeStatus('This kata has no editable recipe to fork.'); return; }
5033
- const recipe = JSON.parse(JSON.stringify(k.recipe));
5034
- const ids = Array.isArray(k.ids) ? k.ids.slice() : null;
 
 
 
 
 
 
 
 
 
5035
  const forked_from = { root: k.root || null, contributor: k.contributor_id || '', contributor_name: k.contributor || '' };
5036
  const entry = { lid: 'k' + Date.now().toString(36), name: (k.label || 'Kata') + ' (fork)', recipe, ids, root: (ids && ids[0]) || null, forked_from, ts: Date.now() };
5037
  const a = loadMyKatas(); a.unshift(entry); saveMyKatas(a);
5038
  composeScope = 'mine';
5039
- setComposeStatus('Forked “' + (k.label || 'kata') + '” into My Katas.');
5040
  playKata({ lid: entry.lid, root: entry.root, label: entry.name, count: recipe.length, recipe, ids, forked_from, local: true });
5041
  }
5042
  // Space per-move bridge: hand ONE move (its two pinned stances + the already-built
@@ -5052,7 +5064,8 @@ _COMPOSE_JS = r"""
5052
  const prefix = [];
5053
  for (let k = 1; k < i; k++) { const pc = composeStrip[k]; if (pc.clipId && !pc.dirty) prefix.push(pc.clipId); else break; }
5054
  const payload = { move: Object.assign({ index: i }, move), prefix_ids: prefix };
5055
- if (!prefix.length) { payload.recipe = composeRecipe(); payload.owner = myAnonId(); if (composeCurrentKata && composeCurrentKata.forked_from) payload.forked_from = composeCurrentKata.forked_from; } // root move stamps the recipe + fork lineage
 
5056
  ta.value = JSON.stringify(payload); ta.dispatchEvent(new Event('input', { bubbles: true }));
5057
  composePending = true;
5058
  setTimeout(() => btn.click(), 50);
@@ -6497,15 +6510,15 @@ def _compose_single_move_logged_in(
6497
  kfs = mv.get("keyframes") or []
6498
  if len(kfs) < 2:
6499
  raise gr.Error("move needs 2 keyframes (start + end stance)")
6500
- # Recipe (+ owning browser) is stamped on the ROOT move so MY KARATE can reopen it.
 
 
6501
  root_extra = {}
 
 
6502
  if is_root:
6503
- if parsed.get("recipe"):
6504
- root_extra["compose_recipe"] = parsed["recipe"]
6505
  if parsed.get("owner"):
6506
  root_extra["compose_owner"] = str(parsed["owner"])[:64]
6507
- # Fork lineage (set when this kata was forked from a community kata) — keeps
6508
- # attribution to the original author in the published data.
6509
  ff = parsed.get("forked_from")
6510
  if isinstance(ff, dict) and (ff.get("root") or ff.get("contributor")):
6511
  root_extra["compose_forked_from"] = {
@@ -6525,7 +6538,7 @@ def _compose_single_move_logged_in(
6525
  prev_id = prefix_ids[-1] if prefix_ids else None
6526
  meta, npz_path, payload, record = _generate_between_uncached(
6527
  prompt, seconds, steps, seed, record_id, kfs, prev_id, generation_model, creator or {},
6528
- root_extra=(root_extra if is_root else None),
6529
  )
6530
  if not isinstance(payload, dict) or record is None:
6531
  err = meta.get("error") if isinstance(meta, dict) else "generation failed"
 
4700
  if (byEl) {
4701
  const k = composeCurrentKata; const bits = [];
4702
  if (!k.local && k.contributor) bits.push('by ' + k.contributor);
4703
+ if (k.forked_from) bits.push('⑂ ' + (k.forked_from.contributor_name || 'anonymous'));
4704
  byEl.textContent = bits.join(' · ');
4705
  }
4706
  box.classList.add('show');
 
4792
  // count all descendants (not just the spine) + 1 for the root
4793
  const all = {}; const stack = [rid];
4794
  while (stack.length) { const x = stack.pop(); (children[x] || []).forEach((c) => { if (!all[c]) { all[c] = 1; stack.push(c); } }); }
4795
+ // Recipe = the most complete one stamped along the chain (every move stamps the
4796
+ // recipe-so-far; the LAST move holds them all — the root's is truncated to move 1).
4797
+ let recipe = m.compose_recipe || null;
4798
+ for (const sid of path) { const sm = byId[sid]; const r = sm && sm.compose_recipe; if (r && r.length && (!recipe || r.length > recipe.length)) recipe = r; }
4799
  let label = recipe && recipe.length ? recipeLabel(recipe) : (m.prompt || rid);
4800
  // Attribution: the root's creator name, else the first spine move that has one
4801
  // (older roots were built anonymously but their later moves may carry a name).
 
4856
  // Attribution: who made it (Community), and fork lineage if any.
4857
  const meta = document.createElement('div'); meta.className = 'kimodo-cz-katameta';
4858
  if (!k.local) { const by = document.createElement('div'); by.className = 'kimodo-cz-kataby'; by.textContent = 'by ' + (k.contributor || 'anonymous'); meta.appendChild(by); }
4859
+ if (k.forked_from) { const ff = document.createElement('div'); ff.className = 'kimodo-cz-katafrom'; ff.textContent = '⑂ forked from ' + (k.forked_from.contributor_name || 'anonymous'); meta.appendChild(ff); }
4860
  // filmstrip: the recipe's stance poses (start/end accented), else the spine clips
4861
  const film = document.createElement('div'); film.className = 'kimodo-cz-film';
4862
  const frames = k.recipe && k.recipe.length ? k.recipe.map((r) => r.id) : k.ids;
 
5032
  // Fork a community kata into My Katas: copy its recipe + spine (clips are reused so it
5033
  // plays instantly), keep attribution to the original author, and open it editable.
5034
  function forkKata(k) {
5035
+ const ids = Array.isArray(k.ids) ? k.ids.slice() : [];
5036
+ let recipe = (k.recipe && k.recipe.length) ? JSON.parse(JSON.stringify(k.recipe)) : [];
5037
+ // The COMPLETE kata is the spine (ids). Older/community katas often stamped only a
5038
+ // truncated recipe (the first move's stances) or none at all — reconstruct the
5039
+ // missing moves from the spine clips so the fork has ALL moves. A recipe needs
5040
+ // ids.length + 1 stances (stance i-1 → i = move i, clip ids[i-1]).
5041
+ if (!recipe.length && ids.length) recipe.push({ id: ids[0], name: 'start', prompt: '', seconds: 2.2, tx: 0, tz: 0, rot: 0 });
5042
+ while (recipe.length < ids.length + 1) {
5043
+ const i = recipe.length; const cid = ids[i - 1] || ids[ids.length - 1] || ('m' + i);
5044
+ recipe.push({ id: cid, name: 'move ' + i, prompt: '', seconds: 2.2, tx: 0, tz: 0, rot: 0 });
5045
+ }
5046
+ if (!recipe.length) { setComposeStatus('This kata can’t be forked (no moves found).'); return; }
5047
  const forked_from = { root: k.root || null, contributor: k.contributor_id || '', contributor_name: k.contributor || '' };
5048
  const entry = { lid: 'k' + Date.now().toString(36), name: (k.label || 'Kata') + ' (fork)', recipe, ids, root: (ids && ids[0]) || null, forked_from, ts: Date.now() };
5049
  const a = loadMyKatas(); a.unshift(entry); saveMyKatas(a);
5050
  composeScope = 'mine';
5051
+ setComposeStatus('Forked “' + (k.label || 'kata') + '” into My Katas (' + ids.length + ' move' + (ids.length === 1 ? '' : 's') + ').');
5052
  playKata({ lid: entry.lid, root: entry.root, label: entry.name, count: recipe.length, recipe, ids, forked_from, local: true });
5053
  }
5054
  // Space per-move bridge: hand ONE move (its two pinned stances + the already-built
 
5064
  const prefix = [];
5065
  for (let k = 1; k < i; k++) { const pc = composeStrip[k]; if (pc.clipId && !pc.dirty) prefix.push(pc.clipId); else break; }
5066
  const payload = { move: Object.assign({ index: i }, move), prefix_ids: prefix };
5067
+ payload.recipe = composeRecipe(); // stamp the FULL recipe-so-far on EVERY move, so the chain's last move always holds the complete kata (the root's is captured before later moves exist)
5068
+ if (!prefix.length) { payload.owner = myAnonId(); if (composeCurrentKata && composeCurrentKata.forked_from) payload.forked_from = composeCurrentKata.forked_from; } // root move stamps owner + fork lineage
5069
  ta.value = JSON.stringify(payload); ta.dispatchEvent(new Event('input', { bubbles: true }));
5070
  composePending = true;
5071
  setTimeout(() => btn.click(), 50);
 
6510
  kfs = mv.get("keyframes") or []
6511
  if len(kfs) < 2:
6512
  raise gr.Error("move needs 2 keyframes (start + end stance)")
6513
+ # The recipe-so-far is stamped on EVERY move (so the chain's LAST move always holds
6514
+ # the complete kata — the root captured it before later moves existed). Owner + fork
6515
+ # lineage stay on the ROOT only. MY KARATE / community read the longest recipe in the chain.
6516
  root_extra = {}
6517
+ if parsed.get("recipe"):
6518
+ root_extra["compose_recipe"] = parsed["recipe"]
6519
  if is_root:
 
 
6520
  if parsed.get("owner"):
6521
  root_extra["compose_owner"] = str(parsed["owner"])[:64]
 
 
6522
  ff = parsed.get("forked_from")
6523
  if isinstance(ff, dict) and (ff.get("root") or ff.get("contributor")):
6524
  root_extra["compose_forked_from"] = {
 
6538
  prev_id = prefix_ids[-1] if prefix_ids else None
6539
  meta, npz_path, payload, record = _generate_between_uncached(
6540
  prompt, seconds, steps, seed, record_id, kfs, prev_id, generation_model, creator or {},
6541
+ root_extra=(root_extra if root_extra else None),
6542
  )
6543
  if not isinstance(payload, dict) or record is None:
6544
  err = meta.get("error") if isinstance(meta, dict) else "generation failed"
docs/comic_pipeline_plan.md ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Path B plan — script engine + stance cards (tiny-aya edition)
2
+
3
+ Decided 2026-06-12. Scriptwright = **tiny-aya** (`CohereLabs/tiny-aya-global` on
4
+ `polats/tiny-army-tiny-aya-zerogpu`) — already integrated in this Space for the dojo
5
+ prompt helper (`app.py` ~3598: gradio_client → `/generate(system, user, max_tokens,
6
+ temperature)`; `/generate_stream` also available for the chat drawer later). This
7
+ REPLACES the earlier in-Space-GGUF plan for script generation: same plumbing as dojo,
8
+ no new runtime, on-theme small model.
9
+
10
+ ## The workflow
11
+
12
+ ```
13
+ stance record (kmd_…) chat context (MY STANCES drawer)
14
+ │ posed_joints[0], tags, source │ last few lines
15
+ ▼ ▼
16
+ [1] stance_features.py ─► FACTS (all computed, no LLM):
17
+ pose phrase "right leg extended high, support leg straight, fists guarding"
18
+ gag facts why the worst candidate lost ("arms drooped", "barely left ground")
19
+ card stats HEIGHT/REACH/SPREAD/DEPTH/BALANCE 0-99
20
+
21
+ [2] premise bank — offline-authored JSON (~50 "Weiner misreads X as Y" scenarios in
22
+ the user's reference-comic voice, each a 4-beat skeleton with stance slots).
23
+ 3 random premises sampled per call → aya picks/adapts ONE (selection, not improv).
24
+
25
+ [3] tiny-aya /generate
26
+ system: Weiner scriptwright persona + output schema + 2 few-shot scripts
27
+ user: stance name + facts + sampled premises + chat excerpt + nonce (dojo trick)
28
+ out: JSON { premise_id, panels: [ {scene, caption, speaker, sfx?} x4 ],
29
+ flavor } ← flavor = one-line card epithet
30
+ parse: persona_parse-style bracket-balanced extraction; caption length caps;
31
+ TEMPLATE FALLBACK on any failure (comic never blocks on the LLM)
32
+
33
+ [4] klein prompt builder (code): "KW1ENER. A 4-panel comic strip in a 2x2 grid…"
34
+ + per-panel scene lines from the script
35
+ + panel 4 scene = THE REAL POSE PHRASE from [1] ← comic stays honest
36
+ + "No text, no speech bubbles anywhere." (trigger-led, NO style words — proven)
37
+
38
+ [5] klein sidecar + Weiner LoRA (lora='weiner')
39
+ distilled 4-step = in-chat preview (seconds)
40
+ base 50-step g4 = final "inscribe the scroll" render (pending Path A verdict)
41
+
42
+ [6] compositor: exact captions/SFX into bubbles at quadrant anchors
43
+ (comic_compose.py now; JS canvas port for the Space)
44
+ ├─► comics/{stance_id}.png + .json (script, facts, premise_id, transcript) → dataset
45
+
46
+ [7] STANCE CARD composer → cards/{stance_id}.png + manifest entry → library card
47
+ ```
48
+
49
+ ## Stance card
50
+
51
+ One artifact per learned stance: trading-card layout, stats = the SAME features the
52
+ sensei judged candidates by (numbers close the loop: features → judging → card).
53
+
54
+ ```
55
+ ┌──────────────────────────┐
56
+ │ SIDE KICK APEX (RIGHT) │ name = stance_name
57
+ │ ┌──────────────────────┐ │
58
+ │ │ card art: panel-4 │ │ art = comic panel-4 crop (Weiner doing the
59
+ │ │ crop OR dedicated │ │ actual learned pose), fallback: one
60
+ │ │ KW1ENER hero render │ │ dedicated klein render w/ pose phrase
61
+ │ └──────────────────────┘ │
62
+ │ “The sky bows first.” │ flavor line ← tiny-aya
63
+ │ HEIGHT ██████████████ 99 │
64
+ │ REACH █████████░░░░░ 62 │ stats ← stance_features (0-99 scaled)
65
+ │ SPREAD █████████░░░░░ 66 │
66
+ │ DEPTH █░░░░░░░░░░░░░ 5 │
67
+ │ #kick #right · @polats │ tags + creator (existing record fields)
68
+ │ scroll #017 · 📖 comic │ links: comic + stance id (loads pose in viewer)
69
+ └──────────────────────────┘
70
+ ```
71
+
72
+ - Computed demo (real record kmd_1f952002fc964515): pose phrase "right leg extended
73
+ high, support leg straight, fists guarding"; stats HEIGHT 99, REACH 62, SPREAD 66, DEPTH 5.
74
+ - Card face doubles as the MY STANCES library tile (replaces bare skeleton-canvas thumb
75
+ for stances that have been "carded"); tap → stance pose loads in the 3D viewer,
76
+ 📖 → comic.
77
+
78
+ ## Build order
79
+
80
+ 1. `stance_features.py` + phrase table + card stats (shared module — also Phase-1 scorer lib)
81
+ 2. Premise bank (`docs/premises.json`, ~50 entries) + aya system prompt + few-shots
82
+ 3. `_comic_script_test.py`: 8-10 stances → aya scripts → parse-rate + quality eyeball
83
+ 4. `_comic_e2e_test.py`: scripts → klein+LoRA → composite → first no-human comics sheet
84
+ 5. Card composer (`stance_card.py`) → cards for the same stances
85
+ 6. Wire into app.py backend (extension point) + MY STANCES drawer UI
86
+
87
+ ## Open
88
+
89
+ - Path A verdict decides the final-render tier (distilled vs base 50-step).
90
+ - Premise-bank voice check: sample 10 premises past the user before writing all 50.
91
+ - aya temperature: dojo helper uses 1.05 + nonce for variety; scripts likely want ~0.9.
docs/premises_sample.json ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_about": "Karate Weiner 4-koma premise bank — SAMPLE 10 for voice calibration. Formula: earnest absurd claim -> commitment to the bit -> reality flatly intrudes -> Weiner reframes reality as confirmation of mastery. {stance} = the learned stance's name; beats are skeletons for tiny-aya to adapt, not final captions.",
3
+ "premises": [
4
+ {
5
+ "id": "roomba",
6
+ "foil": "a robot vacuum",
7
+ "misread": "a tireless student of footwork",
8
+ "setting": "living room",
9
+ "beats": [
10
+ "Weiner presents his most devoted student, circling the room without rest",
11
+ "Weiner demonstrates the {stance}; the vacuum continues, unmoved by greatness",
12
+ "the vacuum bumps the same wall for the third time",
13
+ "\"He patrols. I strike. The dojo is balanced.\""
14
+ ]
15
+ },
16
+ {
17
+ "id": "sprinkler",
18
+ "foil": "a lawn sprinkler",
19
+ "misread": "a master of rhythm who cannot be evaded",
20
+ "setting": "front yard",
21
+ "beats": [
22
+ "Weiner vows to pass the sprinkler untouched using the {stance}",
23
+ "he advances with total confidence",
24
+ "he is completely soaked mid-pose",
25
+ "\"I am cleansed. Round two.\""
26
+ ]
27
+ },
28
+ {
29
+ "id": "squirrel",
30
+ "foil": "a squirrel",
31
+ "misread": "a rival speed master",
32
+ "setting": "park bench",
33
+ "beats": [
34
+ "Weiner challenges the squirrel to witness his {stance}",
35
+ "he performs it flawlessly, eyes closed for effect",
36
+ "the squirrel has taken his snack and left",
37
+ "\"He strikes where the guard is weakest. The acorn was bait.\""
38
+ ]
39
+ },
40
+ {
41
+ "id": "traffic-cone",
42
+ "foil": "a traffic cone",
43
+ "misread": "an elder who has held one stance for decades",
44
+ "setting": "parking lot",
45
+ "beats": [
46
+ "Weiner bows to the elder who has never once broken his stance",
47
+ "Weiner attempts to match him with the {stance}",
48
+ "Weiner hops in pain after testing the elder's stability with his foot",
49
+ "\"Nine years he has held it. I lasted nine seconds.\""
50
+ ]
51
+ },
52
+ {
53
+ "id": "washing-machine",
54
+ "foil": "a washing machine on spin cycle",
55
+ "misread": "a drunken-style master",
56
+ "setting": "laundry room",
57
+ "beats": [
58
+ "Weiner recognizes the legendary drunken style at once",
59
+ "he mirrors the master's trembling form into the {stance}",
60
+ "Weiner is on the floor; the machine clicks into rinse",
61
+ "\"We sparred to a draw. It is still standing. Barely.\""
62
+ ]
63
+ },
64
+ {
65
+ "id": "microwave",
66
+ "foil": "a microwave",
67
+ "misread": "an impartial judge of kata",
68
+ "setting": "kitchen",
69
+ "beats": [
70
+ "Weiner performs the {stance} before the unblinking judge",
71
+ "he holds the final pose, awaiting the verdict",
72
+ "DING. his leftovers are ready",
73
+ "\"The bell. A perfect score. The judge does not lie.\""
74
+ ]
75
+ },
76
+ {
77
+ "id": "pigeon",
78
+ "foil": "a pigeon standing on one leg",
79
+ "misread": "the last living master of crane style",
80
+ "setting": "rooftop",
81
+ "beats": [
82
+ "Weiner begs the master to teach him the {stance}",
83
+ "he copies the master's form exactly",
84
+ "the pigeon flies away mid-lesson",
85
+ "\"The final lesson: leave before the student surpasses you.\""
86
+ ]
87
+ },
88
+ {
89
+ "id": "mirror",
90
+ "foil": "his own reflection",
91
+ "misread": "his strongest and most persistent rival",
92
+ "setting": "dojo mirror wall",
93
+ "beats": [
94
+ "Weiner faces the rival who has studied his every technique",
95
+ "he unleashes the {stance}; the rival matches it instantly",
96
+ "they glare at each other, neither blinking",
97
+ "\"Evenly matched. Every time. ...Suspicious.\""
98
+ ]
99
+ },
100
+ {
101
+ "id": "wind-chime",
102
+ "foil": "a wind chime",
103
+ "misread": "a sensei who speaks only in moments of true mastery",
104
+ "setting": "porch",
105
+ "beats": [
106
+ "Weiner performs the {stance} for the silent sensei",
107
+ "he holds the pose, waiting for the sensei's voice",
108
+ "the wind does not blow",
109
+ "\"Silence. The highest praise.\""
110
+ ]
111
+ },
112
+ {
113
+ "id": "wet-floor-sign",
114
+ "foil": "a wet floor sign",
115
+ "misread": "a fellow practitioner of the wide stance",
116
+ "setting": "supermarket aisle",
117
+ "beats": [
118
+ "Weiner salutes a brother of the stance, training even here",
119
+ "he joins him, sinking into the {stance}",
120
+ "Weiner is flat on the wet floor beside the unmoved sign",
121
+ "\"He warned me. A true master teaches without words.\""
122
+ ]
123
+ }
124
+ ]
125
+ }
stance_features.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared stance feature library: geometric features over one canonical pose frame
2
+ (posed_joints [22,3], SMPL-X 22, y-up, heading 0 = facing +Z, feet grounded at y=0).
3
+
4
+ Used by: candidate scoring (the sensei's judging spec), pose phrases (LLM/image-prompt
5
+ facts), gag facts (why a candidate lost), and stance-card stats. Pure numpy, no LLM —
6
+ language models consume the OUTPUT of this module and never do spatial reasoning."""
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+
11
+ PARENTS = [-1, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9, 12, 13, 14, 16, 17, 18, 19]
12
+ PEL, LHIP, RHIP, LKNEE, RKNEE, LANK, RANK = 0, 1, 2, 4, 5, 7, 8
13
+ LFOOT, RFOOT, HEAD, LWR, RWR = 10, 11, 15, 20, 21
14
+
15
+
16
+ def _angle(P, a, b, c):
17
+ u, v = P[a] - P[b], P[c] - P[b]
18
+ cos = np.dot(u, v) / (np.linalg.norm(u) * np.linalg.norm(v) + 1e-9)
19
+ return float(np.degrees(np.arccos(np.clip(cos, -1, 1))))
20
+
21
+
22
+ def compute_features(Pf) -> dict:
23
+ """All scalar features for one frame. Keys are stable — scoring specs, the
24
+ phrase table, and card stats all reference them by name."""
25
+ P = np.asarray(Pf, np.float32)
26
+ feats = {
27
+ "pelvis_height": float(P[PEL, 1]),
28
+ "head_height": float(P[HEAD, 1]),
29
+ "left_foot_h": float(P[LFOOT, 1]),
30
+ "right_foot_h": float(P[RFOOT, 1]),
31
+ "foot_spread": float(np.hypot(P[LFOOT, 0] - P[RFOOT, 0], P[LFOOT, 2] - P[RFOOT, 2])),
32
+ "fb_separation": float(abs(P[LFOOT, 2] - P[RFOOT, 2])),
33
+ "knee_angle_l": _angle(P, LHIP, LKNEE, LANK),
34
+ "knee_angle_r": _angle(P, RHIP, RKNEE, RANK),
35
+ "reach_l": float(np.linalg.norm(P[LWR] - P[PEL])),
36
+ "reach_r": float(np.linalg.norm(P[RWR] - P[PEL])),
37
+ "wrist_h_l": float(P[LWR, 1]),
38
+ "wrist_h_r": float(P[RWR, 1]),
39
+ "wrist_fwd_l": float(P[LWR, 2] - P[PEL, 2]), # +Z = forward at heading 0
40
+ "wrist_fwd_r": float(P[RWR, 2] - P[PEL, 2]),
41
+ "wrist_lat_l": float(abs(P[LWR, 0] - P[PEL, 0])),
42
+ "wrist_lat_r": float(abs(P[RWR, 0] - P[PEL, 0])),
43
+ }
44
+ feats["foot_h_max"] = max(feats["left_foot_h"], feats["right_foot_h"])
45
+ feats["foot_h_min"] = min(feats["left_foot_h"], feats["right_foot_h"])
46
+ feats["reach_max"] = max(feats["reach_l"], feats["reach_r"])
47
+ feats["knee_min"] = min(feats["knee_angle_l"], feats["knee_angle_r"])
48
+ return feats
49
+
50
+
51
+ def score(Pf, spec) -> float:
52
+ """Sensei judging: spec = [{feature, weight, direction: 'max'|'min'}].
53
+ Linear weighted sum; direction 'min' negates. Same spec format the LLM emits."""
54
+ feats = compute_features(Pf)
55
+ total = 0.0
56
+ for item in spec:
57
+ v = feats.get(item["feature"], 0.0)
58
+ w = float(item.get("weight", 1.0))
59
+ total += -w * v if item.get("direction") == "min" else w * v
60
+ return total
61
+
62
+
63
+ # ------------------------------------------------------------------ pose phrase
64
+
65
+ def pose_phrase(feats: dict) -> str:
66
+ """Human/LLM/image-prompt description of the pose. Ordered rules, most
67
+ salient first, max 4 clauses."""
68
+ f = feats
69
+ parts = []
70
+ inverted = f["head_height"] < f["pelvis_height"] - 0.05
71
+ # canon() grounds Y_min to 0, so true airborne is undetectable — both feet
72
+ # clearly off the ground is the closest proxy (tucked/jump poses).
73
+ airborne = f["foot_h_min"] > 0.25 and f["pelvis_height"] > 0.55
74
+ # one foot clearly higher than the other = a kick/raise, even if the low
75
+ # foot isn't exactly at 0 (mid-air kicks post-canon).
76
+ lifted = f["foot_h_max"] > 0.45 and (f["foot_h_max"] - f["foot_h_min"]) > 0.35
77
+ if inverted:
78
+ parts.append("body inverted upside down")
79
+ elif lifted:
80
+ side = "left" if f["left_foot_h"] > f["right_foot_h"] else "right"
81
+ knee = f["knee_angle_l"] if side == "left" else f["knee_angle_r"]
82
+ kick = f"{side} leg extended high" if knee > 140 else f"{side} knee raised high"
83
+ if airborne:
84
+ parts.append(f"leaping in mid-air, {kick}")
85
+ else:
86
+ parts.append(kick)
87
+ elif airborne:
88
+ parts.append("leaping in mid-air, body tucked")
89
+ else:
90
+ if f["pelvis_height"] < 0.45:
91
+ parts.append("body low to the ground")
92
+ elif f["pelvis_height"] < 0.88:
93
+ parts.append("hips low in a deep stance")
94
+ if f["fb_separation"] > 0.5 and f["fb_separation"] > 0.65 * f["foot_spread"]:
95
+ parts.append("one foot far forward in a long stance")
96
+ elif f["foot_spread"] > 1.0:
97
+ parts.append("feet very wide apart")
98
+ elif f["foot_spread"] > 0.75:
99
+ parts.append("feet wide apart")
100
+ # arms
101
+ fwd = max(f["wrist_fwd_l"], f["wrist_fwd_r"])
102
+ if fwd > 0.35 and max(f["wrist_h_l"], f["wrist_h_r"]) > 0.75:
103
+ side = "left" if f["wrist_fwd_l"] > f["wrist_fwd_r"] else "right"
104
+ parts.append(f"{side} fist thrust forward")
105
+ elif fwd > 0.3 and min(f["wrist_h_l"], f["wrist_h_r"]) < 0.55 and not inverted:
106
+ parts.append("fist swept low across the body")
107
+ elif f["wrist_lat_l"] > 0.45 and f["wrist_lat_r"] > 0.45:
108
+ parts.append("arms spread wide")
109
+ elif f["reach_max"] < 0.45:
110
+ parts.append("fists guarding")
111
+ # support leg
112
+ if not inverted and not airborne and lifted and f["knee_min"] > 155:
113
+ parts.append("support leg straight")
114
+ return ", ".join(parts[:4]) if parts else "standing naturally"
115
+
116
+
117
+ # ------------------------------------------------------------------- gag facts
118
+
119
+ _LOW_PHRASES = {
120
+ "foot_h_max": "the kicking foot barely left the ground",
121
+ "reach_max": "the arms drooped at his sides",
122
+ "foot_spread": "the feet stayed timidly together",
123
+ "fb_separation": "no step forward at all",
124
+ "wrist_fwd_l": "the punch never left the chest",
125
+ "wrist_fwd_r": "the punch never left the chest",
126
+ }
127
+ _HIGH_PHRASES = {
128
+ "pelvis_height": "the hips stayed stubbornly high",
129
+ "knee_min": "the knees refused to bend",
130
+ }
131
+
132
+
133
+ def gag_facts(loser_feats: dict, spec, top_n: int = 2) -> list[str]:
134
+ """Why the worst candidate lost, in words — feeds the comic's panel-3 gag.
135
+ Picks the spec features where the loser scored most against the direction."""
136
+ scored = []
137
+ for item in spec:
138
+ k = item["feature"]
139
+ v = loser_feats.get(k, 0.0)
140
+ w = float(item.get("weight", 1.0))
141
+ if item.get("direction") == "min":
142
+ scored.append((v * w, _HIGH_PHRASES.get(k, f"{k} stayed high")))
143
+ else:
144
+ scored.append((-v * w, _LOW_PHRASES.get(k, f"{k} fell short")))
145
+ scored.sort(reverse=True)
146
+ return [phrase for _, phrase in scored[:top_n]]
147
+
148
+
149
+ # ------------------------------------------------------------------ card stats
150
+
151
+ def card_stats(feats: dict) -> dict:
152
+ """0-99 trading-card stats — the same features the sensei judges by."""
153
+ def clip99(x):
154
+ return int(np.clip(x, 1, 99))
155
+ f = feats
156
+ return {
157
+ "HEIGHT": clip99(f["foot_h_max"] / 1.5 * 99),
158
+ "REACH": clip99(f["reach_max"] / 0.85 * 99),
159
+ "SPREAD": clip99(f["foot_spread"] / 1.2 * 99),
160
+ "DEPTH": clip99((1.05 - f["pelvis_height"]) / 0.6 * 99),
161
+ "BALANCE": clip99((f["foot_h_max"] - f["foot_h_min"]) / 0.9 * 99),
162
+ }