Spaces:
Running on Zero
Running on Zero
Deploy Karate Wiener (kimodo kata maker)
Browse files- app.py +27 -14
- docs/comic_pipeline_plan.md +91 -0
- docs/premises_sample.json +125 -0
- 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 ||
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 ||
|
| 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 |
-
|
| 5033 |
-
|
| 5034 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
#
|
|
|
|
|
|
|
| 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
|
| 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 |
+
}
|