@polycode-projects/the-mechanical-code-talker 2.7.26 → 2.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -120,10 +120,11 @@ tmct> /exit
120
120
 
121
121
  **[Try it live in your browser →](https://polycode-projects.gitlab.io/the-mechanical-code-talker/)**
122
122
  is a real, interactive chat demo running client-side. Your browser runs the
123
- actual query engine against a small example codebase, no server, no install.
124
- The page leads with the **memory ledger**: every fact as a readable sentence,
125
- drill by clicking the terms inside them, and an in-page chat whose answers
126
- focus the ledger.
123
+ actual query engine, no server, no install. The page opens with a live chat
124
+ you can talk to directly, then embeds the **memory ledger** (every fact as a
125
+ readable sentence, drill by clicking the terms inside them), a Towers-of-Hanoi
126
+ plan replayed move by move, and the two live games above — spider-fly and the
127
+ text adventure — each with a link to open it full-screen.
127
128
 
128
129
  From a clone, two build scripts regenerate that demo so you can check it
129
130
  offline before it deploys. The example graph, the ledger page, and the
@@ -183,7 +184,7 @@ resolves to a real graph traversal or declines cleanly:
183
184
  (*because/although/while*), conditionals, and false-premise flags ("why
184
185
  does X still import Y" when it no longer does).
185
186
 
186
- The full catalog with measured coverage lives in `CAPABILITIES_2.0.3.md` and
187
+ The full catalog with measured coverage lives in `CAPABILITIES_2.7.12.md` and
187
188
  the `BENCHMARK_*.md` reports.
188
189
 
189
190
  **Response finishing.** Before an answer prints, it is segmented into typed
@@ -371,7 +372,7 @@ the plan as a self-contained animated page (see "Two more surfaces" above).
371
372
 
372
373
  ## Play a game with it
373
374
 
374
- Two games run inside an ordinary chat session, no setup.
375
+ Three games run inside an ordinary chat session, no setup.
375
376
 
376
377
  **Guess the number.** Say `I'm thinking of a number between 1 and 100` and
377
378
  tmct guesses by narrowing an interval — answer `higher`, `lower`, or
@@ -393,6 +394,19 @@ a blocked action declines by name, and one of the household moves on its own
393
394
  schedule whether you are there to see it or not. The full worked mystery is
394
395
  pinned step by step in `test/corpus/games/adventure.jsonl`.
395
396
 
397
+ **Two agents, planning against each other.** Say `play spider and fly` (or
398
+ `watch the spider and the fly`) and tmct runs both sides itself — neither is
399
+ player-controlled. A spider hunts a fly across a 10×10 web; each side only
400
+ believes what it can currently see (`vision_radius`, tunable), a fly wanders
401
+ when nothing threatens it and evades when something does, a spider avoids
402
+ other spiders, chases what it believes it sees, and builds a web when it
403
+ holds position. Mass is real: both sides waste away each turn they don't
404
+ eat, and a spider gains exactly the mass of what it catches. You can address
405
+ either side directly (`@spider the fly is east`) to feed it a belief — true
406
+ or false — and watch a wrong assertion mislead it for as long as the real
407
+ target stays out of sight. `tmct.toml`'s `[games.spider-fly]` table tunes
408
+ every rate; the full mechanic is pinned in `test/corpus/games/spider-fly.jsonl`.
409
+
396
410
  ## Learning on a miss
397
411
 
398
412
  A question tmct cannot ground is still an honest miss — but on the cleanest
@@ -1035,18 +1049,18 @@ service. The LLM agent stays outside tmct, as the no-LLM ethos requires.
1035
1049
 
1036
1050
  ## Measuring it
1037
1051
 
1038
- What the 2.0.3 cycle measured, on 2026-07-16. Each figure links to its method
1039
- and carries, in the same row, the caveat that changes what it means. The full
1040
- tables, judge scores, and transcripts are in the linked write-ups.
1052
+ What the 2.7.11/2.7.12 cycle measured, on 2026-07-19. Each figure links to its
1053
+ method and carries, in the same row, the caveat that changes what it means.
1054
+ The full tables, judge scores, and transcripts are in the linked write-ups.
1041
1055
 
1042
- | What it does | Result (2.0.3) | Read the number with this | Method |
1056
+ | What it does | Result (2.7.12) | Read the number with this | Method |
1043
1057
  |---|---|---|---|
1044
- | Multi-hop entailment | 219/219 chat cases and 80/80 kernel cases, 0% fabrication, all six bands pass | 50 of the 219 greens (23%) are graded against a declared floor, not the classical answer. INF-C2's 20/20 grades that the engine answers contradictory memory without fabricating, never that it detects the clash. | `BENCHMARK_INFERENCE_2.0.3.md` |
1045
- | Tool-call planning | 56/56 cases, 100% plan-completion, 100% result-completion, 0% hallucination, every rung A0→C2 | Goal driver. All 11 C2 cases pass, so the ladder now has more headroom than the case set exercises. | `BENCHMARK_AGENT_2.0.3.md` |
1046
- | Groundedness | Every answer carries a source, and an empty graph reports itself empty (`bootstrap-empty` 2.000/2). Judge-scored groundedness 1.857/2 over 98 cases. | The 1.857 is judged (`claude-haiku-4-5-20251001`, `judge-prompt-v1`) at N=1 over 9 of 23 construction shapes, so read it as indicative. The judge runs in the offline eval harness, never in the product. | `BENCHMARK_CEFR_ENGLISH_2.0.3.md` |
1047
- | Abstention (the honest miss) | 0% fabrication across 299 inference rows and 0% hallucination across 168 agent rows | Structural, not a tuned threshold. tmct abstains because nothing matched, so the rows test a property of a no-model design rather than a score. | `BENCHMARK_INFERENCE_2.0.3.md`, `BENCHMARK_AGENT_2.0.3.md` |
1048
- | Determinism | Byte-identical on rerun, 0 verdict changes across 299 inference rows against the prior cycle, a 109-case replay in 877ms at $0 per turn | A property of the no-model pipeline. | `BENCHMARK_INFERENCE_2.0.3.md`, `CAPABILITIES_2.0.3.md` |
1049
- | Dialogue robustness (role and polarity) | An adversarial persona sweep could not force a single role or polarity inversion in 55 probes active/passive, forward/reverse, negation and the converse trap all compiled to the correct canonical shape | The same five-frame sweep (~200 probes) surfaced 25 dialogue dead-ends, eight of them confidently wrong, all from words dropped before the parser rather than from the reasoning. | `BENCHMARK_CONVERSATION_2.0.3.md` |
1058
+ | Multi-hop entailment | 379/379 chat cases and 100/100 kernel cases, 0% fabrication, all bands pass | The case set is unchanged from 2.6.0 (same templates, same counts) the one real move this cycle is INF-4's ceiling-graded count dropping 35→30, five cases that now pass as genuine capability instead of against the declared honest-miss floor. | `BENCHMARK_INFERENCE_2.7.12.md` |
1059
+ | Tool-call planning | 68/68 cases, 100% plan-completion, 100% result-completion, 0% hallucination, every rung A0→C2 | Goal driver. 2.6.0 gated at TOOL-7 (62/66, 94%) this cycle's router uplift (a guarded RECOVER step, a tied-candidate composer) cleared it, a real capability move, not a ruler change. | `BENCHMARK_AGENT_2.7.12.md` |
1060
+ | Groundedness | Every answer carries a source, and an empty graph reports itself empty. Judge-scored mean 1.809/2 over 138 cases, 5 hard fails, 136/138 tier-1. | Judged (`claude-haiku-4-5-20251001`, `judge-prompt-v2`) at N=1. The judge prompt moved v1→v2 since 2.6.0, so this is a measurement, not a clean lever comparison against the prior cycle. The judge runs in the offline eval harness, never in the product. | `BENCHMARK_CEFR_ENGLISH_2.7.12.md` |
1061
+ | Abstention (the honest miss) | 0% fabrication across 479 inference rows (379 chat + 100 kernel) and 0% hallucination across 272 agent rows | Structural, not a tuned threshold. tmct abstains because nothing matched, so the rows test a property of a no-model design rather than a score. | `BENCHMARK_INFERENCE_2.7.12.md`, `BENCHMARK_AGENT_2.7.12.md` |
1062
+ | Determinism | Byte-identical on rerun a 379-case `--replay` clean across 2 runs, no LLM, no network, $0 per turn | A property of the no-model pipeline. | `BENCHMARK_INFERENCE_2.7.12.md` |
1063
+ | Dialogue robustness (persona sweep) | A 6-persona sweep (textbook logician, casual newcomer, new developer, adversarial sceptic, returning user, planning user) fixed 25 of the prior cycle's 29 routed findings (21 clean, 4 with a residual noted); 4 remain broken, 2 in a shape distinct from the original complaint | Free exploration across all six personas surfaced roughly 60 fresh findings beyond the ratchet check — the single highest-signal pattern: tmct's own suggested repair text was itself frequently broken when followed verbatim (since fixed, see `HANDOVER.md`). | `BENCHMARK_CONVERSATION_2.7.11.md` |
1050
1064
 
1051
1065
  Three offline benchmark rigs live in a clone (they are not in the npm
1052
1066
  package). Each replays a committed case set through the real product and
@@ -665,6 +665,7 @@ export const CORPUSES = {
665
665
  ["hope", "/r/IsA", "emotion"], ["hope", "/r/CapableOf", "inspire_action"],
666
666
  ["surprise", "/r/IsA", "emotion"], ["surprise", "/r/CapableOf", "cause_astonishment"],
667
667
  ["pride", "/r/IsA", "emotion"], ["pride", "/r/CapableOf", "bring_satisfaction"],
668
+ ["sad", "/r/IsA", "emotion"],
668
669
  ["name", "/r/UsedFor", "identifying_a_person"], ["name", "/r/PartOf", "identity"],
669
670
  ["song", "/r/HasA", "word"], ["song", "/r/UsedFor", "entertaining"],
670
671
  ["music", "/r/HasA", "song"], ["music", "/r/CapableOf", "bring_joy"],
@@ -622,6 +622,7 @@
622
622
  {"start":"/c/en/surprise","rel":"/r/CapableOf","end":"/c/en/cause_astonishment","weight":1,"surfaceText":"[[surprise]] CapableOf [[cause astonishment]]"}
623
623
  {"start":"/c/en/pride","rel":"/r/IsA","end":"/c/en/emotion","weight":1,"surfaceText":"[[pride]] IsA [[emotion]]"}
624
624
  {"start":"/c/en/pride","rel":"/r/CapableOf","end":"/c/en/bring_satisfaction","weight":1,"surfaceText":"[[pride]] CapableOf [[bring satisfaction]]"}
625
+ {"start":"/c/en/sad","rel":"/r/IsA","end":"/c/en/emotion","weight":1,"surfaceText":"[[sad]] IsA [[emotion]]"}
625
626
  {"start":"/c/en/name","rel":"/r/UsedFor","end":"/c/en/identifying_a_person","weight":1,"surfaceText":"[[name]] UsedFor [[identifying a person]]"}
626
627
  {"start":"/c/en/name","rel":"/r/PartOf","end":"/c/en/identity","weight":1,"surfaceText":"[[name]] PartOf [[identity]]"}
627
628
  {"start":"/c/en/song","rel":"/r/HasA","end":"/c/en/word","weight":1,"surfaceText":"[[song]] HasA [[word]]"}
@@ -67,9 +67,9 @@
67
67
  "tool": "corpus/tier2/generate.mjs"
68
68
  },
69
69
  "file": "human.jsonl",
70
- "facts": 664,
71
- "bytes": 81430,
72
- "sha256": "040e6d1f6761a29a080f7825ff8aae60caf879125c1f7a1480340ef04129bcd5",
70
+ "facts": 665,
71
+ "bytes": 81540,
72
+ "sha256": "7710c88c4c96a73fbbae19fe1989b0be40fed5ed40205016e90cc221bcb79607",
73
73
  "license": "MPL-2.0"
74
74
  },
75
75
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "2.7.26",
3
+ "version": "2.8.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
@@ -23,6 +23,7 @@ import { fileURLToPath } from "node:url";
23
23
  import { join, dirname } from "node:path";
24
24
  import { parse as parseToml } from "smol-toml";
25
25
  import { expandMaterialReferences } from "../../domain/sprite-materials.mjs";
26
+ import { expandExpressionReferences } from "../../domain/sprite-expressions.mjs";
26
27
 
27
28
  const HERE = dirname(fileURLToPath(import.meta.url));
28
29
  /** The sprite-tier template directory (data, not code) — every *.toml file
@@ -41,7 +42,10 @@ export const SPRITE_LARGE_TEMPLATES_DIR = join(HERE, "..", "..", "..", "data", "
41
42
  * never go blank because one hand-authored sprite has a typo.
42
43
  * test/adapters/sprite-large-template-files.test.mjs parses the real files
43
44
  * directly (not through this lenient loader) so a broken file still fails a
44
- * test loudly. */
45
+ * test loudly. Material references expand first, expression references
46
+ * second — the two indirections are independent (a template can carry
47
+ * either, both, or neither) so the order between them never matters to the
48
+ * result, only that both run before a template reaches a caller. */
45
49
  export function readSpriteLargeTemplateFiles(dir = SPRITE_LARGE_TEMPLATES_DIR) {
46
50
  let files;
47
51
  try {
@@ -58,5 +62,5 @@ export function readSpriteLargeTemplateFiles(dir = SPRITE_LARGE_TEMPLATES_DIR) {
58
62
  continue; // one malformed file never takes the others down
59
63
  }
60
64
  }
61
- return expandMaterialReferences(templates);
65
+ return expandExpressionReferences(expandMaterialReferences(templates));
62
66
  }
@@ -0,0 +1,120 @@
1
+ // sprite-expressions.mjs — the shared face-fragment scheme the sprite tier's
2
+ // mgx:feels parameter draws from, one small dot/curve-eyes-plus-mouth
3
+ // drawing per curated emotion word, mirroring sprite-materials.mjs's own
4
+ // by-name-reference shape exactly: a frozen palette of raw treatment data,
5
+ // and a pure expand function that turns a short by-name reference in a
6
+ // template's own [parameters.emotion.values] table into the full
7
+ // substitution — so a face fragment is authored ONCE here and referenced by
8
+ // name from every data/sprites-large/*-with-emotion.toml file, rather than
9
+ // six-plus files each hand-copying the same eyes-and-mouth paths.
10
+ //
11
+ // The one real difference from a material treatment: a hex triple needs no
12
+ // context to drop into a template's placeholder, but a face fragment does —
13
+ // classes vary enough in head geometry (a dog's head circle is
14
+ // cx=7 cy=9 r=3.6, a cat's is cx=9 cy=9.4 r=4.4, a person's is
15
+ // cx=12 cy=6.6 r=3.3) that one universal anchor position would misplace the
16
+ // eyes on at least one of them. So EXPRESSION_PALETTE's own fragments are
17
+ // authored in a UNIT face — eyes and mouth drawn as if the head were a
18
+ // radius-1 circle centred on the origin — and expandExpressionReferences
19
+ // wraps the matched fragment in `<g transform="translate({cx} {cy})
20
+ // scale({scale})">` using the SAME template's own required `[face]` table
21
+ // (cx/cy/scale) to place and size it for that one class's real head. A
22
+ // template with a [parameters.emotion] table but no [face] table is left
23
+ // unexpanded (spriteTemplateProblems flags the pairing as a real problem —
24
+ // see sprite-templates.mjs — this function itself stays defensive, never
25
+ // throws on a malformed template).
26
+ //
27
+ // Every fragment uses a fixed dark ink colour rather than currentColor —
28
+ // a face needs to read against ANY body fill (a gold lamp's flame stays a
29
+ // fixed amber for the same reason: what the face/flame looks like doesn't
30
+ // come from what the body is made of or coloured).
31
+ //
32
+ // Convention for every future `*-with-emotion.toml` file (content-authoring
33
+ // agents: read this before copying the shape) — `{{FACE}}` is always the
34
+ // LAST child before `</svg>`, so the resolved face fragment paints over
35
+ // the body/highlight shapes beneath it, never the reverse.
36
+
37
+ /** The dark ink colour every face fragment draws its eyes/mouth in,
38
+ * regardless of the sprite's own body fill — the same fixed-colour
39
+ * reasoning lamp.toml's flame already uses (sprite-templates.mjs's own
40
+ * header explains the precedent). */
41
+ const FACE_INK = "#000000";
42
+
43
+ /** One `<g>`-ready fragment per curated emotion word, dot/curve eyes plus a
44
+ * mouth curve, authored in a unit face (radius 1, centred on the origin) —
45
+ * expandExpressionReferences positions and scales it per class. Distinct
46
+ * by more than mouth curvature alone: scared/surprised both open wide, but
47
+ * scared's mouth stays a small tense "o" while surprised's is a large
48
+ * dropped-jaw oval; calm's eyes are closed contented arcs where happy's
49
+ * are open dots, so no two of the six collapse into the same read at a
50
+ * glance. */
51
+ export const EXPRESSION_PALETTE = Object.freeze({
52
+ happy:
53
+ `<circle cx="-0.4" cy="-0.15" r="0.11" fill="${FACE_INK}" opacity="0.85"/>`
54
+ + `<circle cx="0.4" cy="-0.15" r="0.11" fill="${FACE_INK}" opacity="0.85"/>`
55
+ + `<path d="M -0.4 0.35 Q 0 0.68 0.4 0.35" fill="none" stroke="${FACE_INK}" stroke-width="0.09" stroke-linecap="round" opacity="0.85"/>`,
56
+ sad:
57
+ `<circle cx="-0.4" cy="-0.1" r="0.1" fill="${FACE_INK}" opacity="0.85"/>`
58
+ + `<circle cx="0.4" cy="-0.1" r="0.1" fill="${FACE_INK}" opacity="0.85"/>`
59
+ + `<path d="M -0.38 0.55 Q 0 0.3 0.38 0.55" fill="none" stroke="${FACE_INK}" stroke-width="0.09" stroke-linecap="round" opacity="0.85"/>`,
60
+ angry:
61
+ `<path d="M -0.58 -0.32 L -0.22 -0.16" stroke="${FACE_INK}" stroke-width="0.09" stroke-linecap="round" opacity="0.85"/>`
62
+ + `<path d="M 0.58 -0.32 L 0.22 -0.16" stroke="${FACE_INK}" stroke-width="0.09" stroke-linecap="round" opacity="0.85"/>`
63
+ + `<circle cx="-0.32" cy="-0.02" r="0.09" fill="${FACE_INK}" opacity="0.85"/>`
64
+ + `<circle cx="0.32" cy="-0.02" r="0.09" fill="${FACE_INK}" opacity="0.85"/>`
65
+ + `<path d="M -0.36 0.5 L 0.36 0.42" stroke="${FACE_INK}" stroke-width="0.1" stroke-linecap="round" opacity="0.85"/>`,
66
+ scared:
67
+ `<circle cx="-0.4" cy="-0.12" r="0.2" fill="none" stroke="${FACE_INK}" stroke-width="0.07" opacity="0.85"/>`
68
+ + `<circle cx="-0.4" cy="-0.12" r="0.07" fill="${FACE_INK}" opacity="0.85"/>`
69
+ + `<circle cx="0.4" cy="-0.12" r="0.2" fill="none" stroke="${FACE_INK}" stroke-width="0.07" opacity="0.85"/>`
70
+ + `<circle cx="0.4" cy="-0.12" r="0.07" fill="${FACE_INK}" opacity="0.85"/>`
71
+ + `<circle cx="0" cy="0.42" r="0.09" fill="none" stroke="${FACE_INK}" stroke-width="0.07" opacity="0.85"/>`,
72
+ surprised:
73
+ `<circle cx="-0.38" cy="-0.12" r="0.17" fill="none" stroke="${FACE_INK}" stroke-width="0.07" opacity="0.85"/>`
74
+ + `<circle cx="-0.38" cy="-0.12" r="0.06" fill="${FACE_INK}" opacity="0.85"/>`
75
+ + `<circle cx="0.38" cy="-0.12" r="0.17" fill="none" stroke="${FACE_INK}" stroke-width="0.07" opacity="0.85"/>`
76
+ + `<circle cx="0.38" cy="-0.12" r="0.06" fill="${FACE_INK}" opacity="0.85"/>`
77
+ + `<ellipse cx="0" cy="0.42" rx="0.16" ry="0.22" fill="${FACE_INK}" opacity="0.7"/>`,
78
+ calm:
79
+ `<path d="M -0.5 -0.1 Q -0.4 -0.2 -0.3 -0.1" fill="none" stroke="${FACE_INK}" stroke-width="0.08" stroke-linecap="round" opacity="0.85"/>`
80
+ + `<path d="M 0.3 -0.1 Q 0.4 -0.2 0.5 -0.1" fill="none" stroke="${FACE_INK}" stroke-width="0.08" stroke-linecap="round" opacity="0.85"/>`
81
+ + `<path d="M -0.3 0.4 Q 0 0.5 0.3 0.4" fill="none" stroke="${FACE_INK}" stroke-width="0.08" stroke-linecap="round" opacity="0.85"/>`,
82
+ });
83
+
84
+ /** The mgx:feels property name every emotion-bearing template's
85
+ * `[parameters.emotion]` table names — sprite-templates.mjs never needs
86
+ * to know this constant exists (it reads `param.property` generically),
87
+ * this module only uses it to recognise which parameter table is ITS OWN
88
+ * to expand, the same role sprite-materials.mjs's `param.placeholders`
89
+ * shape check plays for a material table. */
90
+ const FEELS_PROPERTY = "mgx:feels";
91
+
92
+ /**
93
+ * Every `[parameters.emotion].values` string entry, in every template,
94
+ * expanded from a curated emotion-word KEY (e.g. "happy") to the full
95
+ * `<g transform="translate(cx cy) scale(scale)">…</g>` substitution for
96
+ * THIS template's own required `[face]` table — but only for a template
97
+ * whose `[parameters.emotion]` table names `mgx:feels` as its property
98
+ * (sprite-templates.mjs's `param.property`) and that also carries a
99
+ * `[face]` table; a template missing either is left alone (defensive, never
100
+ * throws — spriteTemplateProblems is what flags that pairing as a real
101
+ * authoring mistake). A values entry naming no known palette word is left
102
+ * as the plain string it was, the same never-guess posture
103
+ * expandMaterialReferences already uses for an unknown treatment name.
104
+ * Pure; `templates` is read only.
105
+ */
106
+ export function expandExpressionReferences(templates, palette = EXPRESSION_PALETTE) {
107
+ return (templates || []).map((t) => {
108
+ const emotion = t?.parameters?.emotion;
109
+ if (!emotion?.property || emotion.property !== FEELS_PROPERTY) return t;
110
+ if (!emotion.placeholder || !emotion.values || !t.face) return t;
111
+ const { cx, cy, scale } = t.face;
112
+ const values = {};
113
+ for (const [key, value] of Object.entries(emotion.values)) {
114
+ values[key] = typeof value === "string" && palette[value]
115
+ ? `<g transform="translate(${cx} ${cy}) scale(${scale})">${palette[value]}</g>`
116
+ : value;
117
+ }
118
+ return { ...t, parameters: { ...t.parameters, emotion: { ...emotion, values } } };
119
+ });
120
+ }
@@ -96,20 +96,31 @@ function fillFromValue(svg, param, value) {
96
96
  return null;
97
97
  }
98
98
 
99
- /** Fill a parameterized template's `svg` from the first of its own
100
- * `[parameters.*]` whose observed property value maps to a substitution
101
- * or null when no property fact names a mapped value (never a guess). */
102
- function parameterizedFill(template, propertyFacts) {
99
+ /** Fill a parameterized template's `svg` from EVERY one of its own
100
+ * `[parameters.*]` whose observed property value maps to a substitution,
101
+ * accumulating each successful fill onto the RUNNING svg string rather
102
+ * than stopping at the first hit — so a template declaring both e.g.
103
+ * `[parameters.material]` and `[parameters.emotion]` fills both dimensions
104
+ * in one pass, one parameter's substitution never undoing another's.
105
+ * Returns null when not even one parameter filled (never a guess, and
106
+ * never a half-filled template with a leftover placeholder token) — the
107
+ * caller (resolveAtTerm) falls through to a less specific template exactly
108
+ * as it did before this function tried more than one dimension. */
109
+ function parameterizedFillAll(template, propertyFacts) {
110
+ let svg = template.svg;
111
+ let filledCount = 0;
103
112
  for (const param of Object.values(template.parameters || {})) {
104
113
  const values = param?.values || {};
105
114
  const hit = (propertyFacts || []).find(
106
115
  (f) => f.predicate === param.property && Object.prototype.hasOwnProperty.call(values, f.object),
107
116
  );
108
117
  if (!hit) continue;
109
- const filled = fillFromValue(template.svg, param, values[hit.object]);
110
- if (filled) return filled;
118
+ const filled = fillFromValue(svg, param, values[hit.object]);
119
+ if (!filled) continue;
120
+ svg = filled;
121
+ filledCount += 1;
111
122
  }
112
- return null;
123
+ return filledCount > 0 ? svg : null;
113
124
  }
114
125
 
115
126
  /** Resolve ONE class term (no ancestor walk here — the caller repeats this
@@ -123,7 +134,7 @@ function resolveAtTerm(term, propertyFacts, templates) {
123
134
  if (matched) return matched.svg;
124
135
  for (const t of candidates) {
125
136
  if (t.match || !t.parameters) continue;
126
- const filled = parameterizedFill(t, propertyFacts);
137
+ const filled = parameterizedFillAll(t, propertyFacts);
127
138
  if (filled) return filled;
128
139
  }
129
140
  const plain = candidates.find((t) => !t.match && !t.parameters);
@@ -174,7 +185,12 @@ export function resolveSpriteAsset(className, factRows, propertyFacts, templates
174
185
  * names a `property` and exactly one of `placeholder`/`placeholders` (every
175
186
  * token named appears in `svg`), its `values` map is non-empty and every
176
187
  * entry matches the shape its own `placeholder`/`placeholders` choice
177
- * expects, and a `[match]` table names both `property` and `value`. */
188
+ * expects, a `[match]` table names both `property` and `value`, and
189
+ * `[face]`/`[parameters.emotion]` are always declared TOGETHER — a face
190
+ * anchor with nothing to select it, or an emotion parameter with nowhere to
191
+ * position its face fragment, is a real authoring mistake either way
192
+ * (sprite-expressions.mjs's own header explains why the face fragment
193
+ * needs the pairing). */
178
194
  export function spriteTemplateProblems(template) {
179
195
  const problems = [];
180
196
  const t = template || {};
@@ -218,5 +234,11 @@ export function spriteTemplateProblems(template) {
218
234
  if (t.match && (!t.match.property || t.match.value === undefined)) {
219
235
  problems.push("match is missing property or value");
220
236
  }
237
+ if (t.face && !t.parameters?.emotion) {
238
+ problems.push("face is declared without parameters.emotion — a face anchor with nothing to select it is dead data");
239
+ }
240
+ if (t.parameters?.emotion && !t.face) {
241
+ problems.push("parameters.emotion is declared without a face — an emotion parameter needs its own [face] anchor to position the fragment it fills");
242
+ }
221
243
  return problems;
222
244
  }