@hviana/sema 0.2.9 → 0.4.0

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.
Files changed (84) hide show
  1. package/AGENTS.md +71 -5
  2. package/dist/src/derive/src/deduction.d.ts +12 -1
  3. package/dist/src/derive/src/deduction.js +5 -1
  4. package/dist/src/derive/src/index.d.ts +1 -0
  5. package/dist/src/geometry.d.ts +3 -30
  6. package/dist/src/geometry.js +330 -82
  7. package/dist/src/index.d.ts +1 -0
  8. package/dist/src/index.js +1 -0
  9. package/dist/src/meter.d.ts +171 -0
  10. package/dist/src/meter.js +269 -0
  11. package/dist/src/mind/attention.d.ts +5 -4
  12. package/dist/src/mind/attention.js +254 -23
  13. package/dist/src/mind/bridge.d.ts +10 -1
  14. package/dist/src/mind/bridge.js +179 -10
  15. package/dist/src/mind/canonical.d.ts +6 -1
  16. package/dist/src/mind/canonical.js +6 -1
  17. package/dist/src/mind/graph-search.d.ts +9 -0
  18. package/dist/src/mind/graph-search.js +59 -19
  19. package/dist/src/mind/index.d.ts +2 -0
  20. package/dist/src/mind/junction.d.ts +10 -0
  21. package/dist/src/mind/junction.js +14 -0
  22. package/dist/src/mind/learning.d.ts +32 -4
  23. package/dist/src/mind/learning.js +26 -4
  24. package/dist/src/mind/match.d.ts +40 -0
  25. package/dist/src/mind/match.js +125 -1
  26. package/dist/src/mind/mechanisms/cast.js +63 -6
  27. package/dist/src/mind/mechanisms/extraction.d.ts +0 -34
  28. package/dist/src/mind/mechanisms/extraction.js +1 -88
  29. package/dist/src/mind/mechanisms/recall.d.ts +3 -0
  30. package/dist/src/mind/mechanisms/recall.js +77 -14
  31. package/dist/src/mind/mind.d.ts +59 -5
  32. package/dist/src/mind/mind.js +115 -93
  33. package/dist/src/mind/pipeline-mechanism.d.ts +33 -3
  34. package/dist/src/mind/pipeline-mechanism.js +179 -10
  35. package/dist/src/mind/pipeline.d.ts +29 -0
  36. package/dist/src/mind/pipeline.js +79 -21
  37. package/dist/src/mind/primitives.d.ts +11 -15
  38. package/dist/src/mind/primitives.js +47 -28
  39. package/dist/src/mind/reasoning.d.ts +7 -1
  40. package/dist/src/mind/reasoning.js +40 -8
  41. package/dist/src/mind/recognition.js +93 -20
  42. package/dist/src/mind/traverse.d.ts +11 -0
  43. package/dist/src/mind/traverse.js +88 -7
  44. package/dist/src/mind/types.d.ts +39 -5
  45. package/dist/src/store.d.ts +15 -0
  46. package/dist/src/store.js +91 -6
  47. package/package.json +1 -1
  48. package/src/derive/src/deduction.ts +15 -0
  49. package/src/derive/src/index.ts +1 -0
  50. package/src/geometry.ts +350 -122
  51. package/src/index.ts +1 -0
  52. package/src/meter.ts +333 -0
  53. package/src/mind/attention.ts +276 -31
  54. package/src/mind/bridge.ts +187 -10
  55. package/src/mind/canonical.ts +6 -1
  56. package/src/mind/graph-search.ts +60 -21
  57. package/src/mind/index.ts +6 -0
  58. package/src/mind/junction.ts +12 -0
  59. package/src/mind/learning.ts +46 -5
  60. package/src/mind/match.ts +146 -1
  61. package/src/mind/mechanisms/cast.ts +62 -6
  62. package/src/mind/mechanisms/extraction.ts +2 -103
  63. package/src/mind/mechanisms/recall.ts +84 -17
  64. package/src/mind/mind.ts +144 -99
  65. package/src/mind/pipeline-mechanism.ts +203 -13
  66. package/src/mind/pipeline.ts +144 -36
  67. package/src/mind/primitives.ts +49 -33
  68. package/src/mind/reasoning.ts +39 -7
  69. package/src/mind/recognition.ts +89 -19
  70. package/src/mind/traverse.ts +89 -8
  71. package/src/mind/types.ts +39 -5
  72. package/src/store.ts +75 -6
  73. package/test/14-scaling.test.mjs +17 -7
  74. package/test/31-audit.test.mjs +4 -1
  75. package/test/33-multi-candidate.test.mjs +13 -1
  76. package/test/36-already-answered-fusion.test.mjs +10 -3
  77. package/test/46-recognise-multibyte-edge.test.mjs +3 -3
  78. package/test/53-cross-region-probe-instrumentation.test.mjs +36 -6
  79. package/test/54-evidence-k-instrumentation.test.mjs +175 -0
  80. package/test/55-cost-meter.test.mjs +284 -0
  81. package/test/56-bridge-identity-admission.test.mjs +209 -0
  82. package/test/57-fusion-order.test.mjs +104 -0
  83. package/test/58-subquantum-sites.test.mjs +112 -0
  84. package/test/59-fold-invariance.test.mjs +226 -0
@@ -47,6 +47,27 @@ const SYN_ATTR_CORPUS = [
47
47
  ["blue square", "answer delta"],
48
48
  ];
49
49
 
50
+ // Structural-resonance is the LAST rung: it runs only when every DAG tier —
51
+ // exact junction, single- and double-synonym — found no container at all.
52
+ // SYN_ATTR_CORPUS cannot reach it, and no longer pretends to: every attribute
53
+ // there shares a context ("is a color" / "is a shape") with its neighbours, so
54
+ // the single-synonym tier always finds a bridge and accepts before the ladder
55
+ // gets this far. (It once did reach resonance, when the junction search was
56
+ // phase-dependent and missed containers it should have found; asserting the
57
+ // weaker rung still fires would now be asserting a defect.)
58
+ //
59
+ // This corpus withholds exactly what the synonym tiers need: each attribute's
60
+ // context is unique, so no two attributes are halo siblings, and `red` and
61
+ // `square` never co-occur. `red then square` therefore exhausts the DAG tiers
62
+ // and reaches resonance for real — one probe margin-rejected, one ineligible.
63
+ const NO_BRIDGE_CORPUS = [
64
+ ["red", "warm hue"],
65
+ ["square", "four sides"],
66
+ ["circle", "round form"],
67
+ ["red circle", "answer alpha"],
68
+ ["blue square", "answer delta"],
69
+ ];
70
+
50
71
  // ═══════════════════════════════════════════════════════════════════════════
51
72
  // 1. ordinary-region contrastive rival.
52
73
  // ═══════════════════════════════════════════════════════════════════════════
@@ -204,8 +225,8 @@ test("5. double-synonym tier: probe fields report single returned zero and doubl
204
225
 
205
226
  test("6. structural-resonance: eligible probes report variants, merged proposals and an examined sequence with effectiveScore = annScore * semanticConfidence", async () => {
206
227
  const m = mk(1);
207
- await m.ingest(SYN_ATTR_CORPUS);
208
- const { steps } = await trace(m, "crimson then square");
228
+ await m.ingest(NO_BRIDGE_CORPUS);
229
+ const { steps } = await trace(m, "red then square");
209
230
  await m.store.close();
210
231
 
211
232
  const step = climbStep(steps);
@@ -262,10 +283,10 @@ test("6. structural-resonance: eligible probes report variants, merged proposals
262
283
 
263
284
  test("7. resonance outcomes: ineligible reasons are named and a margin-rejected probe reports a sub-noise-floor margin", async () => {
264
285
  const mIneligible = mk(1);
265
- await mIneligible.ingest(SYN_ATTR_CORPUS);
286
+ await mIneligible.ingest(NO_BRIDGE_CORPUS);
266
287
  const { steps: ineligibleSteps } = await trace(
267
288
  mIneligible,
268
- "crimson beside square",
289
+ "red then square",
269
290
  );
270
291
  await mIneligible.store.close();
271
292
 
@@ -286,9 +307,18 @@ test("7. resonance outcomes: ineligible reasons are named and a margin-rejected
286
307
  assert.equal(p.outcome, "resonance-ineligible");
287
308
  }
288
309
 
310
+ // A margin rejection needs resonance to actually PRODUCE a proposal and
311
+ // then find it indistinguishable from its runner-up. On the tight
312
+ // `red then square` the single surviving proposal clears the noise floor and
313
+ // is accepted; spreading the two attributes apart puts many more pairs in
314
+ // play, and the pair that reaches resonance proposes an ANN neighbour its
315
+ // rival matches — which is exactly the case this field exists to report.
289
316
  const mMargin = mk(1);
290
- await mMargin.ingest(SYN_ATTR_CORPUS);
291
- const { steps: marginSteps } = await trace(mMargin, "crimson then square");
317
+ await mMargin.ingest(NO_BRIDGE_CORPUS);
318
+ const { steps: marginSteps } = await trace(
319
+ mMargin,
320
+ "red and a very long interior gap before square",
321
+ );
292
322
  await mMargin.store.close();
293
323
  const marginStep = climbStep(marginSteps);
294
324
  const marginRejected = (marginStep.data.crossRegion?.probes ?? []).filter(
@@ -0,0 +1,175 @@
1
+ // 54-evidence-k-instrumentation.test.mjs — the three purely-additive
2
+ // read-outs added for evidence-breadth measurement tooling:
3
+ // 1. AncestorReach.visited / maxDepth (trace-gated, serialised into
4
+ // ClimbConsensusData.reaches),
5
+ // 2. structured data payloads on "decideGrounding" / "narrowDecision",
6
+ // 3. ingest()'s optional onDeposit provenance callback.
7
+ //
8
+ // Every assertion here checks a READ-OUT; the final test pins that none of
9
+ // them changes the answer (the same invariant tests 52 §6 / 53 §9 pin for
10
+ // the climb instrumentation).
11
+
12
+ import { test } from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import { decodeText, Mind } from "../dist/src/index.js";
15
+ import { SQliteStore } from "../dist/src/store-sqlite.js";
16
+
17
+ const mk = (seed = 1) =>
18
+ new Mind({ seed, store: new SQliteStore({ path: ":memory:" }) });
19
+
20
+ const CORPUS = [
21
+ ["red", "is a color"],
22
+ ["blue", "is a color"],
23
+ ["circle", "is a shape"],
24
+ ["square", "is a shape"],
25
+ ["red circle", "answer alpha"],
26
+ ["red square", "answer beta"],
27
+ ["blue circle", "answer gamma"],
28
+ ["blue square", "answer delta"],
29
+ ];
30
+
31
+ async function trace(mind, q) {
32
+ const steps = [];
33
+ const ans = await mind.respondText(q, (s) => steps.push(s));
34
+ return { steps, ans };
35
+ }
36
+
37
+ // ═══════════════════════════════════════════════════════════════════════════
38
+ // 1. climb read-out: visited / maxDepth
39
+ // ═══════════════════════════════════════════════════════════════════════════
40
+
41
+ test("1. traced climb reaches carry visited/maxDepth; untraced ones do not", async () => {
42
+ const m = mk(1);
43
+ await m.ingest(CORPUS);
44
+
45
+ const { steps } = await trace(m, "red then circle");
46
+ const climb = steps.find((s) => s.mechanism.at(-1) === "climbConsensus");
47
+ assert.ok(climb?.data?.reaches?.length > 0, "expected reach traces");
48
+ for (const r of climb.data.reaches) {
49
+ assert.equal(typeof r.visited, "number");
50
+ assert.equal(typeof r.maxDepth, "number");
51
+ assert.ok(r.visited >= 0);
52
+ assert.ok(r.maxDepth >= 0);
53
+ // A climb that processed no node cannot have ascended. (One processed
54
+ // node can already sit at depth 1: containment seeds start one hop up.)
55
+ if (r.visited === 0) assert.equal(r.maxDepth, 0);
56
+ // Depth is bounded by the number of processed nodes plus any
57
+ // transparent-chain interiors — sanity only: never negative, and zero
58
+ // whenever nothing was climbed.
59
+ assert.ok(r.maxDepth >= 0);
60
+ }
61
+
62
+ // Untraced (no respond in flight): the same contract as `saturation` —
63
+ // instrumentation fields absent when no trace was requested.
64
+ const someNode = climb.data.reaches[0].node;
65
+ const reach = m.edgeAncestors(someNode, 8);
66
+ assert.equal(reach.visited, undefined);
67
+ assert.equal(reach.maxDepth, undefined);
68
+ await m.store.close();
69
+ });
70
+
71
+ // ═══════════════════════════════════════════════════════════════════════════
72
+ // 2. decideGrounding / narrowDecision data payloads
73
+ // ═══════════════════════════════════════════════════════════════════════════
74
+
75
+ test("2. decideGrounding carries every candidate's weight/grade as data, exactly one decided", async () => {
76
+ const m = mk(1);
77
+ await m.ingest(CORPUS);
78
+ const { steps, ans } = await trace(m, "red then circle");
79
+ await m.store.close();
80
+
81
+ const dg = steps.find((s) => s.mechanism.at(-1) === "decideGrounding");
82
+ assert.ok(dg, "expected a decideGrounding step (≥2 candidates)");
83
+ assert.ok(dg.data, "decideGrounding must carry a data payload");
84
+ assert.equal(dg.data.version, 1);
85
+ assert.equal(dg.data.candidates.length, dg.inputs.length);
86
+ for (const c of dg.data.candidates) {
87
+ assert.equal(typeof c.provenance, "string");
88
+ assert.equal(typeof c.weight, "number");
89
+ assert.equal(typeof c.grade, "number");
90
+ assert.equal(typeof c.unexplainedBytes, "number");
91
+ assert.ok(c.unexplainedBytes >= 0);
92
+ }
93
+ const decided = dg.data.candidates.filter((c) => c.decided);
94
+ assert.equal(decided.length, 1, "exactly one candidate wins");
95
+ // The winner's grade is minimal — the data restates the decision rule.
96
+ const minGrade = Math.min(...dg.data.candidates.map((c) => c.grade));
97
+ assert.equal(decided[0].grade, minGrade);
98
+ assert.ok(ans.length > 0);
99
+
100
+ if (dg.data.runnerUpMargin !== undefined) {
101
+ assert.ok(dg.data.runnerUpMargin >= 0);
102
+ const nd = steps.find((s) => s.mechanism.at(-1) === "narrowDecision");
103
+ if (dg.data.runnerUpMargin <= 1) {
104
+ assert.ok(nd, "margin ≤ 1 must emit narrowDecision");
105
+ assert.equal(nd.data.version, 1);
106
+ assert.equal(nd.data.margin, dg.data.runnerUpMargin);
107
+ } else {
108
+ assert.equal(nd, undefined);
109
+ }
110
+ }
111
+ });
112
+
113
+ // ═══════════════════════════════════════════════════════════════════════════
114
+ // 3. onDeposit provenance callback
115
+ // ═══════════════════════════════════════════════════════════════════════════
116
+
117
+ test("3. onDeposit reports one item-indexed record per ingested item, ids content-addressed", async () => {
118
+ const m = mk(1);
119
+ const reports = [];
120
+ await m.ingest(CORPUS, undefined, (r) => reports.push(r));
121
+
122
+ assert.equal(reports.length, CORPUS.length);
123
+ reports.forEach((r, i) => {
124
+ assert.equal(r.index, i);
125
+ assert.equal(r.kind, "pair");
126
+ assert.equal(typeof r.contextId, "number");
127
+ assert.equal(typeof r.continuationId, "number");
128
+ // The reported ids ARE the items' content-addressed nodes.
129
+ assert.equal(decodeText(m.store.bytes(r.contextId)), CORPUS[i][0]);
130
+ assert.equal(decodeText(m.store.bytes(r.continuationId)), CORPUS[i][1]);
131
+ });
132
+ // Content addressing: identical continuations share one node id.
133
+ const colorIds = [reports[0], reports[1]].map((r) => r.continuationId);
134
+ assert.equal(colorIds[0], colorIds[1]);
135
+ await m.store.close();
136
+ });
137
+
138
+ test("3b. onDeposit reports bare items as kind 'one'; omitting it changes nothing", async () => {
139
+ const m = mk(1);
140
+ const reports = [];
141
+ await m.ingest(
142
+ ["standalone note", ["ctx", "cont"]],
143
+ undefined,
144
+ (r) => reports.push(r),
145
+ );
146
+ assert.deepEqual(reports.map((r) => [r.index, r.kind]), [[0, "one"], [
147
+ 1,
148
+ "pair",
149
+ ]]);
150
+ assert.equal(reports[1].continuationId !== undefined, true);
151
+ assert.equal(reports[0].continuationId, undefined);
152
+ await m.store.close();
153
+ });
154
+
155
+ // ═══════════════════════════════════════════════════════════════════════════
156
+ // 4. none of the read-outs changes the answer
157
+ // ═══════════════════════════════════════════════════════════════════════════
158
+
159
+ test("4. answers are identical with and without the new read-outs attached", async () => {
160
+ const ask = async (withHooks) => {
161
+ const m = mk(7);
162
+ await m.ingest(CORPUS, undefined, withHooks ? () => {} : undefined);
163
+ const out = [];
164
+ for (
165
+ const q of ["red then circle", "red circle blue square", "blue square"]
166
+ ) {
167
+ out.push(
168
+ withHooks ? await m.respondText(q, () => {}) : await m.respondText(q),
169
+ );
170
+ }
171
+ await m.store.close();
172
+ return out;
173
+ };
174
+ assert.deepEqual(await ask(true), await ask(false));
175
+ });
@@ -0,0 +1,284 @@
1
+ // 55-cost-meter.test.mjs — the cross-stack computational-usage meter
2
+ // (src/meter.ts).
3
+ //
4
+ // The meter's four contracts, one test each:
5
+ // 1. OFF BY DEFAULT — an unprofiled Mind never attaches one, and the store
6
+ // is left with no meter after a response either way.
7
+ // 2. NEVER READ BY INFERENCE — profiled and unprofiled answers are
8
+ // byte-identical, and so is the provenance.
9
+ // 3. COUNTS ARE DETERMINISTIC — the same query on the same store meters
10
+ // identically (only the millisecond fields may differ).
11
+ // 4. THE WHOLE STACK REPORTS — store reads, perception, recognition,
12
+ // the mechanism market and the graph search all appear.
13
+ //
14
+ // Plus the two aggregation helpers (sumReports, formatReport) and the
15
+ // multi-turn lifecycle (respondTurn meters through the SAME beginResponse /
16
+ // endResponse pair respond() uses).
17
+
18
+ import { test } from "node:test";
19
+ import assert from "node:assert/strict";
20
+ import { formatReport, Mind, sumReports } from "../dist/src/index.js";
21
+ import { SQliteStore } from "../dist/src/store-sqlite.js";
22
+
23
+ const mk = (opts = {}) =>
24
+ new Mind({ seed: 1, store: new SQliteStore({ path: ":memory:" }), ...opts });
25
+
26
+ /** A small store with enough structure that every layer does some work. */
27
+ async function trained(opts) {
28
+ const mind = mk(opts);
29
+ await mind.ingest([
30
+ ["the capital of France is", " Paris"],
31
+ ["the capital of Japan is", " Tokyo"],
32
+ ["the capital of Italy is", " Rome"],
33
+ ["Paris is in", " Europe"],
34
+ ["Tokyo is in", " Asia"],
35
+ ]);
36
+ return mind;
37
+ }
38
+
39
+ const QUERY = "the capital of France is";
40
+
41
+ test("1. profiling is off by default and leaves no meter on the store", async () => {
42
+ const mind = await trained();
43
+ assert.equal(mind.lastCost, null);
44
+ await mind.respondText(QUERY);
45
+ assert.equal(mind.lastCost, null, "no report without { profile: true }");
46
+ assert.equal(mind.store.meter, null, "store meter never attached");
47
+ assert.equal(mind.meter, null);
48
+ await mind.store.close();
49
+ });
50
+
51
+ test("2. profiling never changes the answer", async () => {
52
+ const plain = await trained();
53
+ const profiled = await trained({ profile: true });
54
+
55
+ for (const q of [QUERY, "Paris is in", "the capital of Japan is", "zzz"]) {
56
+ const a = await plain.respond(q);
57
+ const b = await profiled.respond(q);
58
+ assert.deepEqual(
59
+ Array.from(b.bytes),
60
+ Array.from(a.bytes),
61
+ `answer changed under profiling for "${q}"`,
62
+ );
63
+ assert.equal(b.provenance, a.provenance);
64
+ }
65
+ await plain.store.close();
66
+ await profiled.store.close();
67
+ });
68
+
69
+ test("3. the meter is detached and the report published after each response", async () => {
70
+ const mind = await trained({ profile: true });
71
+ await mind.respondText(QUERY);
72
+
73
+ assert.equal(mind.meter, null, "meter torn down with the response");
74
+ assert.equal(mind.store.meter, null, "store detached — no cross-charging");
75
+
76
+ const r = mind.lastCost;
77
+ assert.ok(r, "a report was published");
78
+ assert.equal(r.version, 1);
79
+ assert.equal(r.queryBytes, QUERY.length);
80
+ assert.ok(r.elapsedMs >= 0);
81
+ await mind.store.close();
82
+ });
83
+
84
+ test("4. counters are deterministic across identical calls on identical stores", async () => {
85
+ const a = await trained({ profile: true });
86
+ const b = await trained({ profile: true });
87
+ await a.respondText(QUERY);
88
+ await b.respondText(QUERY);
89
+ assert.deepEqual(
90
+ a.lastCost.counters,
91
+ b.lastCost.counters,
92
+ "same query + same store ⇒ same work; a diff here is a real regression",
93
+ );
94
+ // Phase CALL counts are deterministic too (their millisecond totals are not).
95
+ const calls = (r) =>
96
+ Object.fromEntries(
97
+ Object.entries(r.phases).map(([k, v]) => [k, v.calls]),
98
+ );
99
+ assert.deepEqual(calls(a.lastCost), calls(b.lastCost));
100
+ await a.store.close();
101
+ await b.store.close();
102
+ });
103
+
104
+ test("5. every layer of the stack reports", async () => {
105
+ const mind = await trained({ profile: true });
106
+ await mind.respondText(QUERY);
107
+ const c = mind.lastCost.counters;
108
+
109
+ // Store layer — identity, content, structure.
110
+ assert.ok(c.leafLookups > 0, "content-addressed leaf lookups counted");
111
+ assert.ok(c.branchLookups > 0, "content-addressed branch lookups counted");
112
+ assert.ok(c.byteReads > 0, "node byte reads counted");
113
+ assert.ok(c.bytesRead > 0, "read VOLUME counted, not just call count");
114
+
115
+ // Mind layer — perception and recognition.
116
+ assert.ok(c.perceptions > 0, "perceptions counted");
117
+ assert.ok(c.perceivedBytes >= c.perceptions, "perceived volume counted");
118
+ assert.ok(c.recognitions > 0, "recognitions counted");
119
+ assert.ok(c.resolves > 0, "identity resolutions counted");
120
+
121
+ // The mechanism market — every mechanism was offered the query.
122
+ assert.ok(
123
+ c.mechanismFloors + c.mechanismSkips >= 5,
124
+ "each built-in mechanism's floor() was accounted",
125
+ );
126
+ assert.ok(c.mechanismRuns > 0, "at least one mechanism ran");
127
+ assert.ok(c.candidates > 0, "candidates weighed");
128
+
129
+ // Phases are per-mechanism and named after the mechanism itself.
130
+ const phases = Object.keys(mind.lastCost.phases);
131
+ assert.ok(phases.includes("think"));
132
+ assert.ok(phases.includes("articulate"));
133
+ assert.ok(
134
+ phases.some((p) => p.endsWith(".floor")),
135
+ "per-mechanism floor phases present",
136
+ );
137
+ await mind.store.close();
138
+ });
139
+
140
+ test("6. zero-valued counters are omitted, and no bookkeeping leaks in", async () => {
141
+ const mind = await trained({ profile: true });
142
+ await mind.respondText(QUERY);
143
+ const c = mind.lastCost.counters;
144
+ for (const [k, v] of Object.entries(c)) {
145
+ assert.notEqual(v, 0, `${k} is zero and should have been dropped`);
146
+ assert.ok(!k.startsWith("_"), `${k} is meter bookkeeping, not work`);
147
+ }
148
+ await mind.store.close();
149
+ });
150
+
151
+ test("7. a bigger query costs more than a smaller one", async () => {
152
+ const mind = await trained({ profile: true });
153
+ await mind.respondText("Paris");
154
+ const small = mind.lastCost.counters;
155
+ await mind.respondText(
156
+ "the capital of France is and the capital of Japan is and Paris is in",
157
+ );
158
+ const big = mind.lastCost.counters;
159
+ assert.ok(
160
+ big.perceivedBytes > small.perceivedBytes,
161
+ "perception cost tracks query length",
162
+ );
163
+ await mind.store.close();
164
+ });
165
+
166
+ test("8. respondTurn meters through the same lifecycle", async () => {
167
+ const mind = await trained({ profile: true });
168
+ const conv = mind.beginConversation();
169
+ await mind.respondTurnText(conv, "the capital of France is");
170
+ const first = mind.lastCost;
171
+ assert.ok(first, "a turn publishes a report");
172
+ assert.ok(first.counters.perceptions > 0);
173
+ assert.equal(mind.store.meter, null, "detached after the turn too");
174
+
175
+ await mind.respondTurnText(conv, "Paris is in");
176
+ const second = mind.lastCost;
177
+ assert.notEqual(second, null);
178
+ assert.ok(
179
+ second.queryBytes > first.queryBytes,
180
+ "a turn is metered against the ACCUMULATED context, which grows",
181
+ );
182
+ mind.endConversation(conv);
183
+ await mind.store.close();
184
+ });
185
+
186
+ test("9. conversation memos survive the shared lifecycle (turn 2 re-uses turn 1)", async () => {
187
+ const mind = await trained({ profile: true });
188
+ const conv = mind.beginConversation();
189
+ await mind.respondTurnText(conv, "the capital of France is");
190
+ await mind.respondTurnText(conv, "the capital of Japan is");
191
+ const c = mind.lastCost.counters;
192
+ // The conversation's perceive/recognise memos are swapped in by
193
+ // beginResponse; the second turn must therefore HIT them for the prefix it
194
+ // shares with the first. A zero here means respondTurn stopped carrying
195
+ // the conversation's persistent state — the exact drift the shared
196
+ // lifecycle exists to prevent.
197
+ assert.ok(
198
+ (c.perceiveHits ?? 0) > 0 || (c.recogniseHits ?? 0) > 0,
199
+ "the prefix's earlier results were re-used across turns",
200
+ );
201
+ mind.endConversation(conv);
202
+ await mind.store.close();
203
+ });
204
+
205
+ test("9b. every phase reports the work done inside it, and phases nest", async () => {
206
+ const mind = await trained({ profile: true });
207
+ await mind.respondText(QUERY);
208
+ const { phases, counters } = mind.lastCost;
209
+
210
+ // Each phase carries counter deltas, not just a duration.
211
+ const withWork = Object.entries(phases).filter(([, p]) =>
212
+ Object.keys(p.counters).length > 0
213
+ );
214
+ assert.ok(withWork.length > 0, "phases attribute work, not only time");
215
+
216
+ // `think` is the outermost inference phase, so its counters must DOMINATE
217
+ // every phase nested inside it — that is what "inclusive" means, and it is
218
+ // the property that makes the deltas readable as attribution.
219
+ const think = phases["think"];
220
+ assert.ok(think, "think is a phase");
221
+ for (const [name, p] of Object.entries(phases)) {
222
+ if (name === "think" || name === "articulate") continue;
223
+ for (const [k, v] of Object.entries(p.counters)) {
224
+ assert.ok(
225
+ (think.counters[k] ?? 0) >= v,
226
+ `${name}.${k}=${v} exceeds think.${k}=${think.counters[k] ?? 0} — ` +
227
+ `phases must nest`,
228
+ );
229
+ }
230
+ }
231
+ // And no phase may claim more of a counter than the whole response spent.
232
+ for (const [name, p] of Object.entries(phases)) {
233
+ for (const [k, v] of Object.entries(p.counters)) {
234
+ assert.ok(
235
+ (counters[k] ?? 0) >= v,
236
+ `${name}.${k}=${v} exceeds the response total ${counters[k] ?? 0}`,
237
+ );
238
+ }
239
+ }
240
+ await mind.store.close();
241
+ });
242
+
243
+ test("9c. a recursive read is one read, not one per node descended", async () => {
244
+ const mind = await trained({ profile: true });
245
+ await mind.respondText(QUERY);
246
+ const c = mind.lastCost.counters;
247
+ // bytesRead is the byte VOLUME, byteReads the number of read REQUESTS.
248
+ // Reconstructing a branch used to charge one read per node descended, which
249
+ // made byteReads track tree size — it read as ~1 byte per read. Real reads
250
+ // return whole forms, so the volume must comfortably exceed the count.
251
+ assert.ok(c.byteReads > 0 && c.bytesRead > 0);
252
+ assert.ok(
253
+ c.bytesRead > c.byteReads,
254
+ `bytesRead ${c.bytesRead} should exceed byteReads ${c.byteReads} — ` +
255
+ `a per-node charge would invert this`,
256
+ );
257
+ await mind.store.close();
258
+ });
259
+
260
+ test("10. sumReports and formatReport aggregate a battery", async () => {
261
+ const mind = await trained({ profile: true });
262
+ const reports = [];
263
+ for (const q of [QUERY, "Paris is in", "the capital of Italy is"]) {
264
+ await mind.respondText(q);
265
+ reports.push(mind.lastCost);
266
+ }
267
+ const total = sumReports(reports);
268
+ assert.equal(total.version, 1);
269
+ assert.equal(
270
+ total.queryBytes,
271
+ reports.reduce((s, r) => s + r.queryBytes, 0),
272
+ );
273
+ for (const key of Object.keys(reports[0].counters)) {
274
+ assert.equal(
275
+ total.counters[key],
276
+ reports.reduce((s, r) => s + (r.counters[key] ?? 0), 0),
277
+ `${key} did not sum`,
278
+ );
279
+ }
280
+ const text = formatReport(total);
281
+ assert.match(text, /^cost: /);
282
+ assert.match(text, /perceptions/);
283
+ await mind.store.close();
284
+ });