@hviana/sema 0.2.3 → 0.2.5

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 (60) hide show
  1. package/dist/src/geometry.d.ts +26 -0
  2. package/dist/src/geometry.js +32 -3
  3. package/dist/src/mind/attention.d.ts +246 -7
  4. package/dist/src/mind/attention.js +771 -173
  5. package/dist/src/mind/bridge.d.ts +30 -0
  6. package/dist/src/mind/bridge.js +569 -0
  7. package/dist/src/mind/index.d.ts +2 -0
  8. package/dist/src/mind/junction.d.ts +30 -1
  9. package/dist/src/mind/junction.js +73 -18
  10. package/dist/src/mind/match.d.ts +15 -2
  11. package/dist/src/mind/match.js +3 -8
  12. package/dist/src/mind/mechanisms/cast.d.ts +54 -0
  13. package/dist/src/mind/mechanisms/cast.js +268 -37
  14. package/dist/src/mind/mechanisms/cover.js +16 -31
  15. package/dist/src/mind/mechanisms/recall.js +66 -0
  16. package/dist/src/mind/mind.d.ts +2 -0
  17. package/dist/src/mind/rationale.d.ts +7 -2
  18. package/dist/src/mind/rationale.js +6 -5
  19. package/dist/src/mind/reasoning.d.ts +11 -0
  20. package/dist/src/mind/reasoning.js +58 -2
  21. package/dist/src/mind/recognition.js +127 -7
  22. package/dist/src/mind/traverse.js +90 -25
  23. package/dist/src/mind/types.d.ts +57 -2
  24. package/dist/src/mind/types.js +38 -7
  25. package/dist/src/store.d.ts +12 -3
  26. package/dist/src/store.js +9 -3
  27. package/package.json +1 -1
  28. package/src/geometry.ts +52 -3
  29. package/src/mind/attention.ts +1199 -128
  30. package/src/mind/bridge.ts +596 -0
  31. package/src/mind/index.ts +16 -0
  32. package/src/mind/junction.ts +125 -16
  33. package/src/mind/match.ts +19 -5
  34. package/src/mind/mechanisms/cast.ts +290 -38
  35. package/src/mind/mechanisms/cover.ts +23 -36
  36. package/src/mind/mechanisms/recall.ts +79 -0
  37. package/src/mind/mind.ts +15 -0
  38. package/src/mind/rationale.ts +12 -4
  39. package/src/mind/reasoning.ts +71 -2
  40. package/src/mind/recognition.ts +132 -7
  41. package/src/mind/traverse.ts +91 -24
  42. package/src/mind/types.ts +95 -6
  43. package/src/store.ts +19 -5
  44. package/test/36-already-answered-fusion.test.mjs +128 -0
  45. package/test/37-cluster-dispersion-fusion.test.mjs +190 -0
  46. package/test/38-reason-restate-guard.test.mjs +94 -0
  47. package/test/39-cast-restate-guard.test.mjs +102 -0
  48. package/test/40-choosenext-scale-guard.test.mjs +75 -0
  49. package/test/41-seatofnode-direction.test.mjs +85 -0
  50. package/test/42-recognise-trace-idempotence.test.mjs +106 -0
  51. package/test/43-cast-analog-seat.test.mjs +244 -0
  52. package/test/44-recognise-edge-whitespace.test.mjs +63 -0
  53. package/test/45-liftanswer-restated-trim.test.mjs +60 -0
  54. package/test/46-recognise-multibyte-edge.test.mjs +85 -0
  55. package/test/47-cast-comparison-coverage.test.mjs +134 -0
  56. package/test/48-recognise-turn-connective.test.mjs +125 -0
  57. package/test/49-natural-units-synonym-bridge.test.mjs +175 -0
  58. package/test/50-cast-analog-consensus-floor.test.mjs +179 -0
  59. package/test/51-structural-resonance-ladder.test.mjs +552 -0
  60. package/test/52-climb-consensus-instrumentation.test.mjs +324 -0
@@ -8,12 +8,11 @@
8
8
  // cover runs FIRST in defaultMechanisms: a computed-backed cover becomes a
9
9
  // near-zero-cost incumbent that prunes the other mechanisms through the
10
10
  // ordinary admissible-floor check, with no extension special-case anywhere.
11
- import { bytesEqual, indexOf } from "../../bytes.js";
12
11
  import { read, resolve } from "../primitives.js";
13
12
  import { guidedFirst } from "../traverse.js";
14
13
  import { conceptHop } from "../match.js";
15
14
  import { bridge } from "../resonance.js";
16
- import { liftAnswer } from "../types.js";
15
+ import { liftAnswer, segRestatesQuery } from "../types.js";
17
16
  import { decodeText, unexplainedLabel } from "../rationale.js";
18
17
  import { rItem, rNode, traceDerivation } from "../trace.js";
19
18
  // ── Concept / connector pre-resolution ──────────────────────────────────────
@@ -166,46 +165,32 @@ export const coverMechanism = {
166
165
  : "lightest derivation: the chosen spans, left to right");
167
166
  if (segs === null)
168
167
  return [];
168
+ const W = ctx.space.maxGroup;
169
169
  // A chosen span's SUBSTITUTED bytes (an edge followed from a recognised
170
170
  // site, not the site's own literal text read back) that equal a byte
171
171
  // span the query ALREADY CONTAINS elsewhere restates part of the
172
- // question — never an answer (the same principle recall.ts's tiers
173
- // apply to a whole-query projection: "a projection that is a proper
174
- // byte-subspan of the query restates part of the question"). A
172
+ // question — never an answer (see {@link segRestatesQuery}). A
175
173
  // recognised site that is itself an entire PRIOR TURN of a multi-turn
176
174
  // query is exactly this shape: it carries a genuine learnt
177
175
  // continuation, but that continuation is something the asker already
178
- // said moments later in the SAME query, not a new answer — the cover
179
- // search has no notion of "turn" to gate this itself, so the check
180
- // belongs here, over the derivation it already chose.
181
- const W = ctx.space.maxGroup;
182
- for (const s of segs) {
183
- if (!s.rec)
184
- continue;
185
- const literal = s.j - s.i === s.bytes.length &&
186
- bytesEqual(s.bytes, query.subarray(s.i, s.j));
187
- if (literal)
188
- continue;
189
- // A span shorter than one river window is exactly the "ice"/"c"
190
- // case: a chain hop's short terminal coincidentally recurring as a
191
- // substring of an unrelated longer word. Below the quantum, byte
192
- // overlap is chance, not evidence — the same floor identityBar and
193
- // reachThreshold hold every other structural-overlap claim to.
194
- if (s.bytes.length >= W && s.bytes.length < query.length &&
195
- indexOf(query, s.bytes, 0) >= 0) {
196
- ctx.trace?.step("restatedSpan", [rItem(s.bytes, "substituted", s.node, [s.i, s.j])], [], "the chosen span's substitution already occurs elsewhere in the query — restates it, not an answer");
197
- return [];
198
- }
176
+ // said moments later in the SAME query. liftAnswer TRIMS such spans
177
+ // out of both the framing decision and the final concatenation the
178
+ // OTHER spans a derivation chose are independent evidence and must not
179
+ // be discarded along with the stale one.
180
+ const restated = segs.filter((s) => segRestatesQuery(s, query, query.length, W));
181
+ if (restated.length > 0) {
182
+ ctx.trace?.step("restatedSpan", restated.map((s) => rItem(s.bytes, "substituted", s.node, [s.i, s.j])), [], "the chosen span's substitution already occurs elsewhere in the query — trimmed from the answer, not an answer itself");
199
183
  }
200
- const composed = liftAnswer(segs, query.length);
184
+ const composed = liftAnswer(segs, query.length, query, W);
201
185
  if (composed === null)
202
186
  return [];
203
187
  ctx.trace?.step("liftAnswer", segs.map((s) => rItem(s.bytes, s.rec ? "chosen" : "scaffolding", s.node, [s.i, s.j])), [rItem(composed, "answer", resolve(ctx, composed) ?? undefined)], "lift the recognised region out of the asker's framing", tCover ? [tCover.index] : undefined);
204
- // accounted = RECOGNISED cover spans only — PASS-carried bytes
205
- // are priced in cost already; the diagnostic label reflects the
206
- // same distinction.
188
+ // accounted = RECOGNISED, non-restating cover spans only — a trimmed
189
+ // restated span contributes nothing to the composed answer, so it must
190
+ // not be priced as if it did (PASS-carried bytes are priced already;
191
+ // the diagnostic label reflects the same distinction).
207
192
  const accounted = segs
208
- .filter((s) => s.rec)
193
+ .filter((s) => s.rec && !segRestatesQuery(s, query, query.length, W))
209
194
  .map((s) => [s.i, s.j]);
210
195
  return [{
211
196
  bytes: composed,
@@ -12,6 +12,7 @@ import { follow, project, reverseContext } from "../match.js";
12
12
  import { CONCEPT, STEP } from "../graph-search.js";
13
13
  import { unexplainedLabel } from "../rationale.js";
14
14
  import { rItem, rNode } from "../trace.js";
15
+ import { substitutionBridge } from "../bridge.js";
15
16
  /** Recall the answer by resonating the whole query against the content index. */
16
17
  export async function recallByResonance(ctx, query, pre) {
17
18
  const t = ctx.trace?.enter("recallByResonance", [
@@ -209,6 +210,71 @@ export async function recallByResonance(ctx, query, pre) {
209
210
  }
210
211
  }
211
212
  }
213
+ // 3b. Corroborated-substitution bridge — refusal-path only (bridge.ts).
214
+ // Every gist-based tier has failed; before refusing, align the query
215
+ // byte-for-byte against the trained contexts its own stored windows
216
+ // anchor, accepting mismatches only as corpus-attested, concept-bar
217
+ // substitutions. A bridged context grounds exactly like any hit —
218
+ // projected through its learnt edges — under the same restated-fragment
219
+ // guard tiers 0b/2 apply. Costs nothing on any answering path.
220
+ {
221
+ // The resonance hits already ranked above are handed to the bridge as
222
+ // PROPOSED candidates alongside its own anchor climbs: on a corpus this
223
+ // size a W-byte window is far too common for the clamped climb to
224
+ // single out the right trained context, while the whole-query gist
225
+ // already ranked it nearest (observed live: "what is the capital of
226
+ // france" resonating straight to "What is the capital of France?" yet
227
+ // refusing on the reach bar). Approximate scores propose; the bridge's
228
+ // byte-exact alignment and attestation gates decide.
229
+ //
230
+ // The proposal breadth here is widened PAST `k` — first by requesting
231
+ // hubBound(ctx) candidates instead of `k` (recall's own tiers above
232
+ // stay at `k`; this re-resonates only on the refusal path, exactly
233
+ // where the bridge itself already runs), AND by asking the index to
234
+ // search EXHAUSTIVELY. Both matter: the IVF only ever probes
235
+ // ⌈√clusters⌉ of them (store.ts's efFor) REGARDLESS of k — widening k
236
+ // alone just returns more hits from the SAME already-probed clusters,
237
+ // never a hit whose vector lives in an unprobed one. Measured live:
238
+ // "What is the chemical symbol for water?" needs "What is the
239
+ // chemical formula for water?", scoring only 0.58 against the
240
+ // query's gist (a MIDDLE-of-string word swap perturbs the river-fold
241
+ // tree hash far more than a same-length TAIL swap like the "carbon"/
242
+ // "oxygen" neighbours that outrank it at 0.87+) — absent from the
243
+ // resonance list even at k=5000, present and byte-exact-verified the
244
+ // moment it's force-fed to the bridge directly. `exhaustive` is the
245
+ // natural, tuning-free ceiling (probe every cluster) for a call that
246
+ // is ALREADY refusal-path-only and must not miss a candidate hiding
247
+ // behind an unlucky structural distance.
248
+ const wide = k < hubBound(ctx)
249
+ ? await ctx.store.resonate(queryGist, hubBound(ctx), true)
250
+ : whole;
251
+ const bridged = await substitutionBridge(ctx, query, wide.map((h) => h.id));
252
+ if (bridged !== null) {
253
+ const g = await project(ctx, bridged.id, queryGist);
254
+ // A projection contained in a substituted candidate-side span is the
255
+ // substitution RESTATED as if it were knowledge — the exact failure
256
+ // observed live: "Darwin was born in England." bridged to the
257
+ // Einstein fact through " England." → " Germany." and would have
258
+ // voiced "Germany", an answer the substitution itself manufactured.
259
+ // The same principle as the restated-fragment guards above, extended
260
+ // to the bridge's own substitutions.
261
+ const cBytes = ctx.store.bytes(bridged.id);
262
+ const manufactured = g !== null &&
263
+ bridged.subs.some((s) => indexOf(cBytes.subarray(s.cs, s.ce), g, 0) >= 0);
264
+ if (g !== null && g.length > 0 && !restates(g) && !manufactured &&
265
+ !(g.length < query.length && indexOf(query, g, 0) >= 0)) {
266
+ return ground(g, `substitution bridge — a trained context accounts for the query ` +
267
+ `up to ${bridged.subs.length} corroborated substitution(s)`,
268
+ // Accounted NOTHING — the same epistemic humility as the echo
269
+ // tier below: a substitution-bridged grounding is a last resort
270
+ // that must lose to ANY mechanism that actually explained the
271
+ // query (observed: pricing the aligned spans here outweighed
272
+ // extraction's correct answer in the grounding decider), while
273
+ // still beating silence when everything else refused.
274
+ [], CONCEPT * bridged.subs.length + STEP);
275
+ }
276
+ }
277
+ }
212
278
  // The refusal/echo decision. The echo returns a stored form's bytes AS
213
279
  // the answer — a near-identity claim about the query — and identity-grade
214
280
  // decisions are never made on an estimated score ("approximate scores may
@@ -42,6 +42,8 @@ export interface Conversation {
42
42
  readonly id: number;
43
43
  }
44
44
  import type { AttentionRead, MindContext, Recognition } from "./types.js";
45
+ export type { AnchorRejectionReason, ClimbConsensusData, ConsensusAnchorTrace, ConsensusReachTrace, ConsensusRegionTrace, CrossRegionTier, JunctionVoteTrace, RegionOutcome, } from "./attention.js";
46
+ export type { AncestorReach, SaturationReason, SaturationStop, } from "./types.js";
45
47
  export interface MindOptions {
46
48
  seed?: number;
47
49
  recallQueryK?: number;
@@ -59,6 +59,11 @@ export interface RationaleStep {
59
59
  /** A one-line, human account of what the mechanism did and why — the sentence
60
60
  * that turns the data into an explanation. */
61
61
  note?: string;
62
+ /** Optional structured payload — a mechanism-specific, plain-serialisable
63
+ * shape (no Map/Set/vectors/mutable internals) that carries more than the
64
+ * human-readable `note` can, for a debugger or downstream tool to consume
65
+ * programmatically. Never read by inference; purely additive. */
66
+ data?: unknown;
62
67
  }
63
68
  /** The callback {@link Mind.respond} / {@link Mind.respondText} accept. It is
64
69
  * invoked once per completed step, AS the inference unfolds — never batched at
@@ -86,7 +91,7 @@ export interface Scope {
86
91
  /** Close the mechanism: emit its step with these outputs and pop it off the
87
92
  * nesting stack. Idempotent — a second call is ignored, so a `finally` that
88
93
  * closes after an early return is safe. */
89
- done(outputs: RationaleItem[], note?: string): void;
94
+ done(outputs: RationaleItem[], note?: string, data?: unknown): void;
90
95
  }
91
96
  /** The live tracer: a stack of open mechanisms over one {@link Mind.respond}.
92
97
  *
@@ -130,5 +135,5 @@ export declare class Rationale {
130
135
  enter(name: string, inputs: RationaleItem[], deps?: number[]): Scope;
131
136
  /** Record a mechanism that has no sub-steps — its inputs and outputs are both
132
137
  * known at the call site. Returns its index, for a later step to depend on. */
133
- step(name: string, inputs: RationaleItem[], outputs: RationaleItem[], note?: string, deps?: number[]): number;
138
+ step(name: string, inputs: RationaleItem[], outputs: RationaleItem[], note?: string, deps?: number[], data?: unknown): number;
134
139
  }
@@ -109,7 +109,7 @@ export class Rationale {
109
109
  this.lastByName.set(name, index);
110
110
  return index;
111
111
  }
112
- emit(index, mechanism, inputs, outputs, deps, note) {
112
+ emit(index, mechanism, inputs, outputs, deps, note, data) {
113
113
  this.sink({
114
114
  index,
115
115
  mechanism,
@@ -120,6 +120,7 @@ export class Rationale {
120
120
  inputs,
121
121
  outputs,
122
122
  note,
123
+ data,
123
124
  });
124
125
  }
125
126
  /** Enter a mechanism that has sub-steps. Captures its inputs and the nesting
@@ -141,22 +142,22 @@ export class Rationale {
141
142
  };
142
143
  return {
143
144
  index,
144
- done: (outputs, note) => {
145
+ done: (outputs, note, data) => {
145
146
  if (closed)
146
147
  return;
147
148
  closed = true;
148
149
  pop();
149
- emit(index, mechanism, inputs, outputs, resolvedDeps, note);
150
+ emit(index, mechanism, inputs, outputs, resolvedDeps, note, data);
150
151
  },
151
152
  };
152
153
  }
153
154
  /** Record a mechanism that has no sub-steps — its inputs and outputs are both
154
155
  * known at the call site. Returns its index, for a later step to depend on. */
155
- step(name, inputs, outputs, note, deps) {
156
+ step(name, inputs, outputs, note, deps, data) {
156
157
  const mechanism = this.path(name);
157
158
  const resolvedDeps = deps ?? this.defaultDeps();
158
159
  const index = this.reserve(name);
159
- this.emit(index, mechanism, inputs, outputs, resolvedDeps, note);
160
+ this.emit(index, mechanism, inputs, outputs, resolvedDeps, note, data);
160
161
  return index;
161
162
  }
162
163
  }
@@ -1,5 +1,16 @@
1
1
  import type { MindContext } from "./types.js";
2
2
  import type { Precomputed } from "./pipeline-mechanism.js";
3
+ /** Whether `bytes` is a proper byte-subspan of `query` — already present in
4
+ * the question, so voicing it back only restates part of what was asked,
5
+ * never answers it. The exact guard recallByResonance already applies to
6
+ * its OWN grounding candidates (tier 1's `restates`, tier 2's subspan
7
+ * check, tier 0b's argument-binding subspan check) — every mechanism that
8
+ * walks a LEARNT CONTINUATION EDGE past an already-vetted grounding
9
+ * (reason()'s own hops below, and CAST's `projectCounterfactual` seat
10
+ * substitution — see cast.ts) needs the same guard applied to what the
11
+ * walk turns up, since `follow()`/`chooseNext`/`pivotInto` know nothing of
12
+ * the query at all — only of what structurally continues what. */
13
+ export declare function restatesQuery(query: Uint8Array, bytes: Uint8Array): boolean;
3
14
  /** Extend a grounded answer forward across facts (multi-hop reasoning).
4
15
  * Pivots on the longest unconsumed learnt context each answer contains,
5
16
  * then follows the pivot's continuation to the next fact. Repeats up
@@ -8,6 +8,19 @@ import { resolve } from "./primitives.js";
8
8
  import { corpusN } from "./traverse.js";
9
9
  import { follow, haloSiblings, project } from "./match.js";
10
10
  import { joinWithBridge, pivotInto } from "./resonance.js";
11
+ /** Whether `bytes` is a proper byte-subspan of `query` — already present in
12
+ * the question, so voicing it back only restates part of what was asked,
13
+ * never answers it. The exact guard recallByResonance already applies to
14
+ * its OWN grounding candidates (tier 1's `restates`, tier 2's subspan
15
+ * check, tier 0b's argument-binding subspan check) — every mechanism that
16
+ * walks a LEARNT CONTINUATION EDGE past an already-vetted grounding
17
+ * (reason()'s own hops below, and CAST's `projectCounterfactual` seat
18
+ * substitution — see cast.ts) needs the same guard applied to what the
19
+ * walk turns up, since `follow()`/`chooseNext`/`pivotInto` know nothing of
20
+ * the query at all — only of what structurally continues what. */
21
+ export function restatesQuery(query, bytes) {
22
+ return bytes.length < query.length && indexOf(query, bytes, 0) >= 0;
23
+ }
11
24
  /** Extend a grounded answer forward across facts (multi-hop reasoning).
12
25
  * Pivots on the longest unconsumed learnt context each answer contains,
13
26
  * then follows the pivot's continuation to the next fact. Repeats up
@@ -81,7 +94,8 @@ export async function reason(ctx, query, answer, preConsumed, pre) {
81
94
  const fwd = await follow(ctx, curId, qv);
82
95
  const fwdId = fwd !== null ? resolve(ctx, fwd) : null;
83
96
  if (fwd !== null && !bytesEqual(fwd, cur) &&
84
- (fwdId === null || !consumed.has(fwdId))) {
97
+ (fwdId === null || !consumed.has(fwdId)) &&
98
+ !restatesQuery(query, fwd)) {
85
99
  consumeAll(curId);
86
100
  t ??= ctx.trace?.enter("reason", [
87
101
  rItem(startedFrom, "grounded"),
@@ -98,7 +112,7 @@ export async function reason(ctx, query, answer, preConsumed, pre) {
98
112
  break;
99
113
  const fc = await follow(ctx, pivot, qv);
100
114
  consumeAll(pivot);
101
- if (fc === null || bytesEqual(fc, cur))
115
+ if (fc === null || bytesEqual(fc, cur) || restatesQuery(query, fc))
102
116
  break;
103
117
  t ??= ctx.trace?.enter("reason", [rItem(startedFrom, "grounded")]);
104
118
  ctx.trace?.step("pivotStep", [rItem(cur, "answer"), rNode(ctx, pivot, "pivot")], [rItem(fc, "answer", resolve(ctx, fc) ?? undefined)], "pivot on the shared span this answer contains, then step forward across that fact");
@@ -158,6 +172,48 @@ unclimbed = false) {
158
172
  ...rest.map((r) => rNode(ctx, r.anchor, "point", r.vote)),
159
173
  ]);
160
174
  for (const root of rest) {
175
+ // DISPERSION: this root's contributing regions are confined to a
176
+ // single cluster (see Attention.clusters) — one local neighbourhood of
177
+ // the query, not several separate places. Raw region count already
178
+ // failed to discriminate a coincidental match from a genuine further
179
+ // topic (test/24 gap 3.1 vs test/35's echo); dispersion is a different
180
+ // question — not how MUCH evidence, but how many separate PLACES in the
181
+ // query corroborate it — and a coincidental match is structurally
182
+ // confined to one cluster no matter how strongly it resonates.
183
+ //
184
+ // EXCEPTION: crossRegionVotes' own joint conclusions (a query naming
185
+ // two attributes that were only ever learnt TOGETHER — test/34's own
186
+ // binding corpus) are inherently ONE fused context and are pooled from
187
+ // a single synthetic region, so they always read as one cluster even
188
+ // though they already, by construction, account for both original
189
+ // mentions. `breadth` (dominates — the same > half-the-query bar used
190
+ // everywhere else) still correctly recognises these: a genuine joint
191
+ // binding explains the MAJORITY of the query's regions on its own,
192
+ // which a coincidental echo never does (verified: test/35's echo tops
193
+ // out at 0.40). So a root is trusted when EITHER measure alone
194
+ // indicates real signal — excluded only when BOTH are weak. Cheap and
195
+ // synchronous — checked before the async already-answered walk below.
196
+ if (root.clusters < 2 && root.breadth <= 0.5) {
197
+ ctx.trace?.step("singleCluster", [rNode(ctx, root.anchor, "point", root.vote)], [], "this point's evidence is confined to one local neighbourhood of the query — not trusted as an independent topic");
198
+ continue;
199
+ }
200
+ // ALREADY ANSWERED: this root's own learnt continuation — the same
201
+ // content-addressed walk reason()'s echo guard already trusts
202
+ // (`ctx.store.prevCount(qId) > 0`), here applied per-candidate instead
203
+ // of to the whole query — is VERBATIM present later in the query. A
204
+ // query that embeds both an exchange's ask and its own already-given
205
+ // reply (a conversation's turn plus its own prior answer, concatenated
206
+ // raw by addTurn — or any caller pasting a transcript into one
207
+ // respond() call; the check is Mind-bookkeeping-free, so it treats both
208
+ // identically) has already spoken this root's answer — fusing it in
209
+ // again would only restate it. Deliberately NOT a magnitude measure:
210
+ // it fires on exact content-addressed recurrence, not on how strongly
211
+ // the root resonates.
212
+ const cont = await follow(ctx, root.anchor, qv);
213
+ if (cont !== null && cont.length > 0 && indexOf(query, cont, root.end) >= 0) {
214
+ ctx.trace?.step("alreadyAnswered", [rNode(ctx, root.anchor, "point", root.vote)], [rItem(cont, "continuation")], "this point's own learnt continuation already appears later in the query — already answered, not fused");
215
+ continue;
216
+ }
161
217
  const g = await project(ctx, root.anchor, qv);
162
218
  if (g === null || g.length === 0)
163
219
  continue;
@@ -7,7 +7,7 @@
7
7
  import { rItem } from "./trace.js";
8
8
  import { canonResolve, foldTree, gistOf, latin1Key, perceive, resolve, } from "./primitives.js";
9
9
  import { atomIsHub, corpusN, leadsSomewhere } from "./traverse.js";
10
- import { chainReach, leafIdAt } from "./canonical.js";
10
+ import { chainReach, leafIdAt, leafIdRun } from "./canonical.js";
11
11
  import { isChunk } from "../sema.js";
12
12
  /** Decompose a byte stream into every stored form that leads somewhere
13
13
  * (has a continuation edge or a halo). Two complementary readings:
@@ -23,13 +23,37 @@ import { isChunk } from "../sema.js";
23
23
  * Both O(n · maxGroup) bounded O(1) probes — never a scan of the corpus. */
24
24
  export function recognise(ctx, bytes) {
25
25
  // Content-keyed memo — works for both single-turn respond() and multi-turn
26
- // respondTurn() (where the map persists across calls). Skipped while
27
- // tracing so every call still emits its rationale step.
28
- if (ctx.recogniseMemo && !ctx.trace) {
26
+ // respondTurn() (where the map persists across calls). ALWAYS consulted,
27
+ // regardless of tracing matching perceive()'s own memo, which carries no
28
+ // trace gate at all. This memo is NOT an optional accelerator: recogniseImpl
29
+ // walks the query's perceived tree via foldTree, whose subtree-resolution
30
+ // fast path (see primitives.ts) skips invoking `visit` — and therefore
31
+ // skips EMITTING SITES — for any subtree already cached in
32
+ // ctx._resolvedSubtrees. A multi-turn conversation's stable-prefix fold
33
+ // deliberately shares node OBJECTS across turns, so by the second call on
34
+ // the exact same bytes, large swaths of the tree are already cached and
35
+ // foldTree stops short of recursing into them — a second recogniseImpl
36
+ // call on the SAME bytes is not idempotent; it silently finds FEWER sites
37
+ // than the first (observed live: 31 sites → 5 on an immediate repeat
38
+ // call). Skipping this memo "only while tracing" used to mean every
39
+ // traced turn re-ran recogniseImpl from scratch at every one of the many
40
+ // call sites that recognise the same query (cover, reason, articulate...),
41
+ // each subsequent call silently more incomplete than the last — measurably
42
+ // changing which mechanism grounds the answer, not just costing time. The
43
+ // trace step must still fire on every call regardless (a cache hit is not
44
+ // silent), so it is emitted here directly instead of only inside
45
+ // recogniseImpl.
46
+ if (ctx.recogniseMemo) {
29
47
  const key = latin1Key(bytes);
30
48
  const hit = ctx.recogniseMemo.get(key);
31
- if (hit !== undefined)
49
+ if (hit !== undefined) {
50
+ ctx.trace?.step("recognise", [rItem(bytes, "query")], hit.sites.map((s) => rItem(bytes.subarray(s.start, s.end), "form", s.payload, [
51
+ s.start,
52
+ s.end,
53
+ ])), `decompose the query into ${hit.sites.length} learnt form(s) that ` +
54
+ `lead somewhere (over ${hit.leaves.length} perceived leaves) [cached]`);
32
55
  return hit;
56
+ }
33
57
  const fresh = recogniseImpl(ctx, bytes);
34
58
  ctx.recogniseMemo.set(key, fresh);
35
59
  return fresh;
@@ -71,9 +95,20 @@ function recogniseImpl(ctx, bytes) {
71
95
  // silence. Atoms stay available as leaves (PASS-carried literals) and
72
96
  // through exact tier-0 resolution regardless.
73
97
  const atomsAreHubs = atomIsHub(ctx, corpusN(ctx));
98
+ // Distinct probes (structural exact match, canon fallback, edge trims at
99
+ // several offsets) can legitimately re-derive the SAME (start, end, id)
100
+ // site from different tree nodes — a wide edge-trim search is exactly
101
+ // this on purpose (see below). Duplicate site entries are not wrong
102
+ // evidence, but they double the weight cover's derivation search gives
103
+ // that span, distorting its cost model — the same span must count once.
104
+ const seen = new Set();
74
105
  const emit = (start, end, id) => {
75
106
  if (id < 0 && atomsAreHubs)
76
107
  return;
108
+ const key = start + "," + end + "," + id;
109
+ if (seen.has(key))
110
+ return;
111
+ seen.add(key);
77
112
  if (leadsSomewhere(ctx, id)) {
78
113
  sites.push({ start, end, payload: id });
79
114
  }
@@ -90,11 +125,96 @@ function recogniseImpl(ctx, bytes) {
90
125
  // missed may still be a stored form under the response's equivalence
91
126
  // (case, width, whitespace — whatever the injected canonicalizer says).
92
127
  // O(subtree bytes) per miss, memoised per response; a no-op when no
93
- // canonicalizer was injected or the store has no canon index.
94
- else if (end - start >= 2) {
128
+ // canonicalizer was injected or the store has no canon index. A raw
129
+ // leaf (n.kids === null) is single-byte and handled by the byte-atom
130
+ // path above instead — canon equivalence only applies to composites.
131
+ else if (n.kids !== null) {
95
132
  const cid = canonResolve(ctx, bytes.subarray(start, end));
96
133
  if (cid !== null)
97
134
  emit(start, end, cid);
135
+ // The edge-trim fallbacks below remove 1 byte from a side; the
136
+ // remainder must still be a composite (>= 2 bytes, the same floor
137
+ // n.kids !== null enforces above) rather than degenerate into
138
+ // single-byte-atom territory, which atomIsHub already governs
139
+ // separately.
140
+ else if (end - start - 1 >= 2) {
141
+ // The chunk's own boundary is drawn by content geometry, not by
142
+ // any notion of "form" — it can include one edge byte the query's
143
+ // fold happened to attach here that the trained span never had
144
+ // (e.g. a separator from the preceding chunk). The core has no
145
+ // idea what that byte means; it only knows resolve()/canonResolve
146
+ // are self-verifying (hash-then-verify, same discipline as every
147
+ // content lookup here), so a blind one-byte-shorter guess on
148
+ // either edge costs nothing when wrong and is trustworthy when it
149
+ // hits. Two extra probes, only on the already-failed miss path.
150
+ const left = resolve(ctx, bytes.subarray(start + 1, end));
151
+ if (left !== null)
152
+ emit(start + 1, end, left);
153
+ const right = resolve(ctx, bytes.subarray(start, end - 1));
154
+ if (right !== null)
155
+ emit(start, end - 1, right);
156
+ // A misalignment wider than one byte (e.g. more than one edge
157
+ // separator swallowed) is not itself geometry-quantized — the
158
+ // WRITE side's canonical index (canonicalWindows) interns sliding
159
+ // W−1/W-length windows over leaf ids at EVERY offset, not just
160
+ // radix-aligned ones (see canonical.ts) — so the offset that
161
+ // recovers a trained span can be anything, not a multiple of W.
162
+ // What IS bounded is how far it's worth looking: chainReach(W)=W²,
163
+ // the same reach the canonical pass (tryChain) trusts for a chain
164
+ // rebuilt off the query's own fold. Every candidate offset is
165
+ // gated by store.findBranch(leafIds) first — the SAME cheap,
166
+ // fold-free existence check tryChain already uses — so the extra
167
+ // resolve() fold (the real cost) is only paid when a branch could
168
+ // plausibly exist there, not for every offset. The node itself is
169
+ // also bounded to chunk-scale (end - start <= W²): widening this at
170
+ // whole-query/root scale can rediscover a smaller subtree's own
171
+ // content as a second, overlapping site the structural walk's own
172
+ // finer recursion already emits correctly on its own — a duplicate
173
+ // that downstream derivation can stitch into a wrong answer.
174
+ const W = ctx.space.maxGroup;
175
+ for (let k = 1; end - start <= W * W && k <= W * W && start + k < end - 1; k++) {
176
+ const lIds = leafIdRun(ctx, bytes, start + k, end);
177
+ if (lIds !== null && store.findBranch(lIds) !== null) {
178
+ const eLeft = resolve(ctx, bytes.subarray(start + k, end));
179
+ if (eLeft !== null)
180
+ emit(start + k, end, eLeft);
181
+ }
182
+ const rIds = leafIdRun(ctx, bytes, start, end - k);
183
+ if (rIds !== null && store.findBranch(rIds) !== null) {
184
+ const eRight = resolve(ctx, bytes.subarray(start, end - k));
185
+ if (eRight !== null)
186
+ emit(start, end - k, eRight);
187
+ }
188
+ }
189
+ // A REAL extra word at the left edge (a discourse connective like
190
+ // "And " prepended to a follow-up turn — not boundary noise, actual
191
+ // content the injected canonicalizer has no equivalence for) shows
192
+ // up as a canon-miss too big for the chunk-scale search above: the
193
+ // turn is its OWN segment (stable-prefix folded independently — see
194
+ // mind.ts's _growContext), so it can be turn/segment-scale, not
195
+ // chunk-scale. Widening the size bound itself reopens the root-
196
+ // scale false-positive this module already fixed once (test/46);
197
+ // widening the SEARCH instead — trying every position up to W
198
+ // chunk-widths from the left edge that the query's OWN fold treats
199
+ // as a chunk boundary (`starts`, the same set the canonical pass
200
+ // privileges with full chain reach) — does not: `starts.has(p)` is
201
+ // fold EVIDENCE the query produced on its own (a leaf-parent chunk
202
+ // like "And " is visited, and its start added to `starts`, before
203
+ // this composite ever runs — foldTree is post-order), never a blind
204
+ // guess. Bounded to W candidates, each an O(1) set lookup before
205
+ // paying for the real canonResolve fold — canonResolve, not
206
+ // resolve()/findBranch, because the gap here is often exactly the
207
+ // kind of equivalence (case, in the live trace) canon exists for,
208
+ // not just an exact-content coincidence.
209
+ for (let k = 1; k <= W; k++) {
210
+ const p = start + k * W;
211
+ if (p >= end - 1 || !starts.has(p))
212
+ continue;
213
+ const cid = canonResolve(ctx, bytes.subarray(p, end));
214
+ if (cid !== null)
215
+ emit(p, end, cid);
216
+ }
217
+ }
98
218
  }
99
219
  if (isChunk(n)) {
100
220
  starts.add(start);