@hviana/sema 0.4.7 → 0.5.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 (63) hide show
  1. package/AGENTS.md +290 -77
  2. package/HOW_IT_WORKS.md +2170 -735
  3. package/dist/example/train_base.d.ts +9 -3
  4. package/dist/example/train_base.js +21 -4
  5. package/dist/src/canon.d.ts +19 -0
  6. package/dist/src/canon.js +28 -0
  7. package/dist/src/geometry.d.ts +52 -0
  8. package/dist/src/geometry.js +87 -1
  9. package/dist/src/mind/bridge.js +27 -1
  10. package/dist/src/mind/frame-filler.d.ts +15 -0
  11. package/dist/src/mind/frame-filler.js +535 -0
  12. package/dist/src/mind/learning.js +6 -11
  13. package/dist/src/mind/mechanisms/cast.js +72 -2
  14. package/dist/src/mind/mechanisms/cover.js +6 -1
  15. package/dist/src/mind/mechanisms/extraction.js +27 -0
  16. package/dist/src/mind/mechanisms/recall.js +214 -34
  17. package/dist/src/mind/mind.d.ts +49 -1
  18. package/dist/src/mind/mind.js +137 -10
  19. package/dist/src/mind/pipeline-mechanism.d.ts +7 -0
  20. package/dist/src/mind/pipeline.js +29 -1
  21. package/dist/src/mind/prefix-completion.d.ts +59 -0
  22. package/dist/src/mind/prefix-completion.js +270 -0
  23. package/dist/src/mind/primitives.d.ts +29 -10
  24. package/dist/src/mind/primitives.js +52 -61
  25. package/dist/src/mind/recognition.js +119 -9
  26. package/dist/src/mind/traverse.d.ts +32 -0
  27. package/dist/src/mind/traverse.js +52 -0
  28. package/dist/src/mind/types.d.ts +55 -16
  29. package/dist/src/mind/types.js +68 -19
  30. package/dist/src/store.d.ts +21 -0
  31. package/dist/src/store.js +21 -0
  32. package/example/train_base.ts +21 -4
  33. package/package.json +1 -1
  34. package/src/canon.ts +28 -0
  35. package/src/geometry.ts +100 -1
  36. package/src/mind/bridge.ts +34 -0
  37. package/src/mind/frame-filler.ts +604 -0
  38. package/src/mind/learning.ts +5 -9
  39. package/src/mind/mechanisms/cast.ts +70 -2
  40. package/src/mind/mechanisms/cover.ts +6 -1
  41. package/src/mind/mechanisms/extraction.ts +27 -0
  42. package/src/mind/mechanisms/recall.ts +236 -37
  43. package/src/mind/mind.ts +154 -14
  44. package/src/mind/pipeline-mechanism.ts +7 -0
  45. package/src/mind/pipeline.ts +33 -1
  46. package/src/mind/prefix-completion.ts +314 -0
  47. package/src/mind/primitives.ts +59 -70
  48. package/src/mind/recognition.ts +117 -6
  49. package/src/mind/traverse.ts +52 -0
  50. package/src/mind/types.ts +98 -42
  51. package/src/store.ts +25 -0
  52. package/test/13-conversation.test.mjs +13 -0
  53. package/test/57-fusion-order.test.mjs +65 -0
  54. package/test/66-query-edge-whitespace.test.mjs +99 -0
  55. package/test/67-climb-anchor-breadth.test.mjs +113 -0
  56. package/test/68-extraction-unanchored.test.mjs +79 -0
  57. package/test/69-frame-filler.test.mjs +115 -0
  58. package/test/70-prefix-completion.test.mjs +170 -0
  59. package/test/71-embedded-canon-equivalence.test.mjs +121 -0
  60. package/test/72-prefix-candidate-supply.test.mjs +114 -0
  61. package/test/73-scaffolding-only-bridge-abstains.test.mjs +178 -0
  62. package/test/74-prefix-trap-not-sprung-early.test.mjs +114 -0
  63. package/test/75-multiturn-context-optimisation.test.mjs +1082 -0
@@ -8,6 +8,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
10
  import { chainReach, leafIdAt, leafIdRun } from "./canonical.js";
11
+ import { canonHash } from "../canon.js";
11
12
  import { isChunk } from "../sema.js";
12
13
  /** Decompose a byte stream into every stored form that leads somewhere
13
14
  * (has a continuation edge or a halo). Two complementary readings:
@@ -400,7 +401,21 @@ function recogniseImpl(ctx, bytes) {
400
401
  // before resolveSpan pays for a fold; approximate evidence never enters.
401
402
  // This tier is needed only where atom chains are suppressed. Small stores
402
403
  // retain their existing decomposition unchanged.
403
- if (atomsAreHubs) {
404
+ // ALWAYS ON, AND LINEAR. This used to be gated on `atomsAreHubs` — small
405
+ // stores were said to "retain their existing decomposition unchanged", which
406
+ // was true only while the query's fold was told where the turns were: every
407
+ // turn was then a NODE, so the structural walk found it and this tier had
408
+ // nothing to add. The fold no longer imposes turn boundaries (a turn start
409
+ // is an ordinary interior offset now), so a trained form embedded in a
410
+ // longer query is reachable ONLY here — the chain caps at chainReach(W)=W²
411
+ // bytes and cannot span one. Measured: with the fold imposing boundaries
412
+ // every turn is a node; without it, none is.
413
+ //
414
+ // Ungating it alone made inference QUADRATIC (test/14's constant-KB/s guard
415
+ // went to 41.8s): every offset near a cut is an endpoint, and each probe
416
+ // costs O(span) to slice the leaf-id run and hash it. The budget below is
417
+ // what makes it affordable — see `spend`.
418
+ {
404
419
  const allLeafIds = singleLeaf.map((x) => x?.id ?? null);
405
420
  if (allLeafIds.every((x) => x !== null)) {
406
421
  const radius = ctx.space.seats.length;
@@ -410,20 +425,115 @@ function recogniseImpl(ctx, bytes) {
410
425
  endpoints.add(p);
411
426
  }
412
427
  const ordered = [...endpoints].sort((a, b) => a - b);
413
- const probe = (start, end) => {
428
+ // The leaf-id run is BYTE-EXACT, while `resolveSpan` behind it resolves
429
+ // exactly OR canonically — so this gate was strictly narrower than its
430
+ // own resolver, and every embedded form differing from its deposit only
431
+ // by the response's equivalence (case, width) was dropped before the
432
+ // resolver ever saw it. Rebuilding the run over canonicalized bytes
433
+ // does NOT fix that: a differently-cased deposit's branch kid-ids are
434
+ // not the query's leaf-id run under ANY canonicalization of the query,
435
+ // so the second admission route has to be the canon INDEX itself — the
436
+ // same candidate proposal `canonResolve` makes, and the same
437
+ // cheap-probe-before-a-fold discipline the exact route already follows
438
+ // (a hash and an indexed lookup; no fold, no vector, no scan). Both
439
+ // routes only PROPOSE; `resolveSpan` still decides, so a hash-bucket
440
+ // collision costs one fold and can never emit a wrong site (test/71).
441
+ const canonAdmits = (start, end) => {
442
+ const canon = ctx.canon;
443
+ if (canon === null || !store.canonFind)
444
+ return false;
445
+ const key = canon(bytes.subarray(start, end));
446
+ if (key.length === 0)
447
+ return false;
448
+ return store.canonFind(canonHash(key)).length > 0;
449
+ };
450
+ // The byte-exact route probes the SPAN ITSELF (see
451
+ // Store.findFlatBranch): for a run of single-byte leaves the flat-kid
452
+ // encoding is the identity, so the span's bytes ARE the branch key.
453
+ // `subarray` is a view — this allocates nothing per probe, and the
454
+ // bloom filter answers the misses without touching the database.
455
+ const flatProbe = (start, end) => store.findFlatBranch
456
+ ? store.findFlatBranch(bytes.subarray(start, end))
457
+ : store.findBranch(allLeafIds.slice(start, end));
458
+ // THE TWO ROUTES COST DIFFERENT THINGS, SO THEY ARE PRICED SEPARATELY.
459
+ //
460
+ // The exact route is a bloom-gated hash over a subarray VIEW: no
461
+ // allocation, and a miss never reaches the database. It is cheap enough
462
+ // to run on every endpoint, and that is what makes this tier able to
463
+ // find a trained form embedded anywhere in the query.
464
+ //
465
+ // The canon route is not: it runs the canonicalizer over the span
466
+ // (NFKC, case-fold, whitespace) and allocates a fresh key for every
467
+ // probe. That is the O(span) cost with the heavy constant, and it is
468
+ // the one worth a budget. Sharing ONE budget between them made the
469
+ // cheap route starve on the expensive one's behalf — measured, test/71's
470
+ // embedded differently-cased form needed 64x the budget to be found,
471
+ // while the exact route it was competing with needed none of it.
472
+ const probe = (start, end, canonBudget) => {
414
473
  if (end - start < W || end - start <= chainReach(W))
415
474
  return;
416
- const ids = allLeafIds.slice(start, end);
417
- if (store.findBranch(ids) === null)
418
- return;
475
+ if (flatProbe(start, end) === null) {
476
+ if (!canonBudget)
477
+ return;
478
+ if (!canonAdmits(start, end))
479
+ return;
480
+ }
419
481
  const id = resolveSpan(start, end);
420
482
  if (id !== null)
421
483
  emit(start, end, id);
422
484
  };
423
- for (const end of ordered)
424
- probe(0, end);
425
- for (const start of ordered)
426
- probe(start, bytes.length);
485
+ // A CUMULATIVE BYTE BUDGET, SPENT SHORTEST-SPAN-FIRST.
486
+ //
487
+ // Each probe costs O(span), and there are O(n) endpoints, so probing
488
+ // them all is O(n²) — the quadratic this tier was gated to avoid. The
489
+ // budget caps TOTAL probe bytes at a multiple of the query's own length,
490
+ // which is what keeps whole-query inference linear.
491
+ //
492
+ // Spending it shortest-first is what makes the cap a scale bound rather
493
+ // than a position bound: the tier recovers embedded forms up to roughly
494
+ // √(2·budget) bytes ANYWHERE in the endpoint set, instead of walking the
495
+ // endpoints in order and running out partway along the query. A form
496
+ // longer than that is out of this tier's reach — but so is a form the
497
+ // chain cannot span, and that is exactly the trade the budget prices.
498
+ // The factor is chainReach(W), the same W² scale the chain already
499
+ // trusts; no new constant.
500
+ // The factor is chainReach(W) — the same W² scale the chain itself
501
+ // trusts — so the cap is derived from the fold's geometry, never tuned.
502
+ // (It was briefly an environment variable while the cost was being
503
+ // measured; an env-read here would make inference non-reproducible,
504
+ // which the determinism contract forbids outright.)
505
+ // The factor is chainReach(W) — the same W² scale the chain itself
506
+ // trusts — so the cap is derived from the fold's geometry, never tuned.
507
+ // (It was briefly an environment variable while the cost was being
508
+ // measured; an env-read here would make inference non-reproducible,
509
+ // which the determinism contract forbids outright.)
510
+ //
511
+ // It now prices ONLY the canonicalizing route; the exact route runs on
512
+ // every endpoint regardless, so exhausting this budget narrows which
513
+ // equivalence-class forms are proposed, never which byte-exact ones.
514
+ let budget = bytes.length * chainReach(W) * chainReach(W);
515
+ const spend = (start, end) => {
516
+ const span = end - start;
517
+ const afford = span <= budget;
518
+ if (afford)
519
+ budget -= span;
520
+ probe(start, end, afford);
521
+ // Always keep walking: the exact route is unbudgeted, so running out
522
+ // of canon budget must not stop the scan.
523
+ return true;
524
+ };
525
+ const prefixes = ordered.filter((e) => e > 0).sort((a, b) => a - b);
526
+ const suffixes = ordered
527
+ .filter((s2) => s2 < bytes.length)
528
+ .sort((a, b) => b - a);
529
+ for (let i = 0; i < Math.max(prefixes.length, suffixes.length); i++) {
530
+ // Interleaved so neither edge starves the other when the budget runs
531
+ // out — a query can carry a trained form at either end.
532
+ if (i < prefixes.length && !spend(0, prefixes[i]))
533
+ break;
534
+ if (i < suffixes.length && !spend(suffixes[i], bytes.length))
535
+ break;
536
+ }
427
537
  }
428
538
  }
429
539
  const chunkEnd = new Uint32Array(bytes.length);
@@ -124,3 +124,35 @@ export declare function chooseAmong(ctx: MindContext, candidates: readonly numbe
124
124
  id: number;
125
125
  score: number;
126
126
  };
127
+ /** True when NO window of `query` discriminates anything — every stored
128
+ * W-window it spells is contained by more places than the hub bound allows,
129
+ * i.e. the whole query is corpus-global scaffolding.
130
+ *
131
+ * WHAT IT IS FOR. Several mechanisms ground a query through the literal
132
+ * spans it did NOT explain, and those spans are the whole of their evidence.
133
+ * When every one of them is a hub, the query says nothing the corpus can be
134
+ * held to, and grounding it means picking one of thousands of continuations
135
+ * it gives no evidence for — a fabrication whatever the answer happens to be.
136
+ * Answering with silence there is the honest degradation contract (§2.13).
137
+ *
138
+ * MEASURED SEPARATION (trained store, hubBound 571) — this is categorical,
139
+ * not marginal, and it is why the predicate lives here rather than being
140
+ * spelled twice:
141
+ * "What is the capital of" ALL saturated ("What":572) → fabricated
142
+ * "What is the capital " ALL saturated ("What":572) → fabricated
143
+ * "what is the capital of france" min "f fr":248 → correct
144
+ * "What is the capitol of France?" min "f Fr":114 → correct
145
+ * "WHAT IS THE CAPITAL OF FRANCE?" min "HE C":1 → correct
146
+ * "What is the capital of France?" min "t i":4 → correct
147
+ * "Who wrote Romeo and Juliet?" min "iet?":26 → correct
148
+ * "What is the capital of Zamunda?" min "Zamu":3 → silent anyway
149
+ * Note the last: the honest-silence probes are already refused on other
150
+ * evidence and sit on the SAME side as the correct ones, so this predicate
151
+ * is not what makes them silent and cannot be credited for them.
152
+ *
153
+ * NO NEW THRESHOLD (§2.2): `hubBound` is the √N reading of "hub" used
154
+ * everywhere, and the containment read is clamped to it exactly as every
155
+ * other fan-out read is (§2.8). A query with no stored window at all is NOT
156
+ * scaffolding-only — it has no evidence either way, and its callers already
157
+ * refuse it on their own terms. */
158
+ export declare function allWindowsAreScaffolding(ctx: MindContext, query: Uint8Array): boolean;
@@ -7,6 +7,7 @@
7
7
  // project) live in match.ts — the elementary match-and-project operation.
8
8
  import { cosine } from "../vec.js";
9
9
  import { gistOf, read } from "./primitives.js";
10
+ import { leafIdRun } from "./canonical.js";
10
11
  const structCaches = new WeakMap();
11
12
  // ── The shared ancestor-reach memo ──────────────────────────────────────
12
13
  //
@@ -648,3 +649,54 @@ function rItemShort(ctx, id, role, score) {
648
649
  score,
649
650
  };
650
651
  }
652
+ /** True when NO window of `query` discriminates anything — every stored
653
+ * W-window it spells is contained by more places than the hub bound allows,
654
+ * i.e. the whole query is corpus-global scaffolding.
655
+ *
656
+ * WHAT IT IS FOR. Several mechanisms ground a query through the literal
657
+ * spans it did NOT explain, and those spans are the whole of their evidence.
658
+ * When every one of them is a hub, the query says nothing the corpus can be
659
+ * held to, and grounding it means picking one of thousands of continuations
660
+ * it gives no evidence for — a fabrication whatever the answer happens to be.
661
+ * Answering with silence there is the honest degradation contract (§2.13).
662
+ *
663
+ * MEASURED SEPARATION (trained store, hubBound 571) — this is categorical,
664
+ * not marginal, and it is why the predicate lives here rather than being
665
+ * spelled twice:
666
+ * "What is the capital of" ALL saturated ("What":572) → fabricated
667
+ * "What is the capital " ALL saturated ("What":572) → fabricated
668
+ * "what is the capital of france" min "f fr":248 → correct
669
+ * "What is the capitol of France?" min "f Fr":114 → correct
670
+ * "WHAT IS THE CAPITAL OF FRANCE?" min "HE C":1 → correct
671
+ * "What is the capital of France?" min "t i":4 → correct
672
+ * "Who wrote Romeo and Juliet?" min "iet?":26 → correct
673
+ * "What is the capital of Zamunda?" min "Zamu":3 → silent anyway
674
+ * Note the last: the honest-silence probes are already refused on other
675
+ * evidence and sit on the SAME side as the correct ones, so this predicate
676
+ * is not what makes them silent and cannot be credited for them.
677
+ *
678
+ * NO NEW THRESHOLD (§2.2): `hubBound` is the √N reading of "hub" used
679
+ * everywhere, and the containment read is clamped to it exactly as every
680
+ * other fan-out read is (§2.8). A query with no stored window at all is NOT
681
+ * scaffolding-only — it has no evidence either way, and its callers already
682
+ * refuse it on their own terms. */
683
+ export function allWindowsAreScaffolding(ctx, query) {
684
+ const W = ctx.space.maxGroup;
685
+ const bound = hubBound(ctx);
686
+ let sawOne = false;
687
+ for (let o = 0; o + W <= query.length; o++) {
688
+ const ids = leafIdRun(ctx, query, o, o + W);
689
+ if (ids === null)
690
+ continue;
691
+ const id = ctx.store.findBranch(ids);
692
+ if (id === null)
693
+ continue;
694
+ const rarity = ctx.store.containersSlice(id, 0, bound + 1).length;
695
+ if (rarity === 0)
696
+ continue;
697
+ if (rarity <= bound)
698
+ return false;
699
+ sawOne = true;
700
+ }
701
+ return sawOne;
702
+ }
@@ -7,22 +7,17 @@ import type { MindConfig } from "../config.js";
7
7
  import type { Meter } from "../meter.js";
8
8
  import type { GraphSearch, Leaf, Seg, Site } from "./graph-search.js";
9
9
  import type { Rationale } from "./rationale.js";
10
- import type { Grid, StableFold } from "../geometry.js";
11
- /** One {@link MindContext._depositTrees} entry — see that field's doc. */
10
+ import type { ContentFold, Grid } from "../geometry.js";
11
+ /** One {@link MindContext._depositTrees} entry — see that field's doc.
12
+ *
13
+ * A PURE WORK CACHE. It carries the already-folded content segments of a
14
+ * deposited stream so a longer stream sharing its byte prefix can skip
15
+ * refolding them. It holds no turn boundaries and no continuation proof
16
+ * because the deposit fold imposes nothing: reuse is bit-identical to a cold
17
+ * fold, so a hit can only save time, never change a tree. */
12
18
  export interface DepositCacheEntry {
13
- /** Turn boundaries accumulated over this content's deposit chain
14
- * strictly increasing proper offsets, each a previously-deposited
15
- * whole-context length. Empty for a first-seen (single-turn) input. */
16
- boundaries: number[];
17
- /** Stable-prefix segment folds (grown-context inputs only). */
18
- stable?: StableFold;
19
- /** The continuation bytes this ctxInput was paired with in ingestPair, if
20
- * any — the ONLY thing that makes a later, longer ctxInput a genuine next
21
- * TURN of the same conversation rather than an unrelated fact that
22
- * happens to share this one's byte prefix (e.g. "2+2" vs. "2+2=5"). A
23
- * later deposit only takes this entry as its stable-prefix `prev` when
24
- * its own suffix bytes-equal this exactly. */
25
- nextBytes?: Uint8Array;
19
+ /** The plain content fold's reusable segment state. */
20
+ content: ContentFold;
26
21
  }
27
22
  export type Input = string | Uint8Array | Grid | Grid[];
28
23
  /** The host capabilities GraphSearch consults during a cover. MindContext
@@ -291,7 +286,17 @@ export interface MindContext extends GraphSearchHost {
291
286
  * walking children. When a conversation's pyramid reuses prefix
292
287
  * subtrees, this cache lets {@link recognise} skip them entirely —
293
288
  * O(suffix) instead of O(context). Mind-lifetime (WeakMap keys are
294
- * the Sema objects the pyramid keeps alive). */
289
+ * the Sema objects the pyramid keeps alive).
290
+ *
291
+ * THAT REUSE IS A PRECONDITION, NOT A GIVEN: the keys are node IDENTITIES,
292
+ * so it hits only while the conversation's fold hands back the SAME Sema
293
+ * objects for the unchanged prefix. `_growContext` rebuilt the whole tree
294
+ * with `bytesToTree` on every turn, so every key was fresh and this cache
295
+ * could not hit even once — the O(suffix) claim above described an
296
+ * intention rather than the code. It now grows the context through
297
+ * {@link stablePrefixFoldIncremental}, which reuses each already-folded
298
+ * segment: measured over four turns, turn 4 shared 69 of its 95 nodes with
299
+ * turn 3 (26 new ≈ the new turn's own size). */
295
300
  _resolvedSubtrees: WeakMap<Sema, {
296
301
  id: number;
297
302
  len: number;
@@ -369,6 +374,40 @@ export declare function segRestatesQuery(s: Seg, query: Uint8Array, queryLen: nu
369
374
  * (lo/hi) decision and the final concatenation: it is stale, not a second
370
375
  * answer, but the OTHER spans a derivation chose are independent evidence
371
376
  * and must not be discarded along with it. */
377
+ /** The spans {@link liftAnswer} actually concatenates, in order — the answer
378
+ * before it is joined. Exposed so a caller can ask what the lifted answer is
379
+ * MADE OF without re-deriving the selection: in particular how much of it is
380
+ * SCAFFOLDING (a `rec: false` span — query bytes carried through verbatim
381
+ * because nothing explained them, the same spans the liftAnswer trace labels
382
+ * "scaffolding" rather than "chosen").
383
+ *
384
+ * That quantity is load-bearing for the grounding decision. Two candidates
385
+ * can leave the SAME number of query bytes unaccounted and therefore grade
386
+ * identically, while one of them pads its answer with those bytes and the
387
+ * other does not — measured on test/22's two-fact chain, cover and recall
388
+ * both graded 11001 with 11 bytes unexplained, and cover won the tie only on
389
+ * consideration order, answering "The capital of France is Paris famous for"
390
+ * where recall had crossed the hop. Carrying an unexplained span into the
391
+ * answer is strictly weaker than not explaining it: it manufactures fluency
392
+ * out of the asker's own words. See the tie-break in pipeline.ts. */
393
+ export declare function liftAnswerParts(segs: Seg[], queryLen: number, query: Uint8Array, W: number): Seg[];
394
+ /** The SCAFFOLDING byte count of a lifted answer: how many of its bytes come
395
+ * from spans nothing recognised (see {@link liftAnswerParts}).
396
+ *
397
+ * ONLY RUNS OF AT LEAST ONE RIVER WINDOW COUNT. Not all carried-through
398
+ * bytes are a failure to explain: a period, a question mark, the space
399
+ * between two fused topics are GLUE — they belong to the answer's surface,
400
+ * and dropping them to look better-derived would be a worse answer, not a
401
+ * more honest one. A substantive phrase the derivation never explained
402
+ * ("famous for") is a different claim entirely.
403
+ *
404
+ * W is the line between them, and it is the same line the rest of the mind
405
+ * already draws: below one river window byte overlap is chance, not evidence
406
+ * (see identityBar, the bridge's attestedQ, and recognition's site floor).
407
+ * Counting every scaffolding byte instead — which is what this did first —
408
+ * made punctuation preservation lose a tie it should win, and test/00's
409
+ * "period preserved" / "question mark preserved" caught it immediately. */
410
+ export declare function liftedScaffolding(segs: Seg[], queryLen: number, query: Uint8Array, W: number): number;
372
411
  export declare function liftAnswer(segs: Seg[], queryLen: number, query: Uint8Array, W: number): Uint8Array | null;
373
412
  /** The CHANGED NODES of a freshly-perceived `tree` against the node ids a previous
374
413
  * tracked deposit interned (`prevSeen`). */
@@ -46,7 +46,23 @@ export function segRestatesQuery(s, query, queryLen, W) {
46
46
  * (lo/hi) decision and the final concatenation: it is stale, not a second
47
47
  * answer, but the OTHER spans a derivation chose are independent evidence
48
48
  * and must not be discarded along with it. */
49
- export function liftAnswer(segs, queryLen, query, W) {
49
+ /** The spans {@link liftAnswer} actually concatenates, in order — the answer
50
+ * before it is joined. Exposed so a caller can ask what the lifted answer is
51
+ * MADE OF without re-deriving the selection: in particular how much of it is
52
+ * SCAFFOLDING (a `rec: false` span — query bytes carried through verbatim
53
+ * because nothing explained them, the same spans the liftAnswer trace labels
54
+ * "scaffolding" rather than "chosen").
55
+ *
56
+ * That quantity is load-bearing for the grounding decision. Two candidates
57
+ * can leave the SAME number of query bytes unaccounted and therefore grade
58
+ * identically, while one of them pads its answer with those bytes and the
59
+ * other does not — measured on test/22's two-fact chain, cover and recall
60
+ * both graded 11001 with 11 bytes unexplained, and cover won the tie only on
61
+ * consideration order, answering "The capital of France is Paris famous for"
62
+ * where recall had crossed the hop. Carrying an unexplained span into the
63
+ * answer is strictly weaker than not explaining it: it manufactures fluency
64
+ * out of the asker's own words. See the tie-break in pipeline.ts. */
65
+ export function liftAnswerParts(segs, queryLen, query, W) {
50
66
  const restated = segs.map((s) => segRestatesQuery(s, query, queryLen, W));
51
67
  const recognised = [];
52
68
  for (let k = 0; k < segs.length; k++) {
@@ -54,32 +70,65 @@ export function liftAnswer(segs, queryLen, query, W) {
54
70
  recognised.push(k);
55
71
  }
56
72
  if (recognised.length === 0)
57
- return null;
73
+ return [];
58
74
  if (recognised.length === 1) {
59
75
  const s = segs[recognised[0]];
60
- // A COMPUTED span's query-side width is operand digit-count, not
61
- // evidence of how much of the query's meaning it accounts for — the
62
- // half-dominance check below (built for a genuinely RECOGNISED learned
63
- // form) is not a valid framing signal for it (see the `computed` field
64
- // doc on Seg/GItem): "1000 - 421" outweighs "what is …?" by width only
65
- // because the operands are big, not because the framing matters less.
66
- // A LITERAL PREFIX before a computed span is unambiguous framing
67
- // regardless of width — an arithmetic expression is never itself
68
- // preceded by more literal computed content, so anything literal before
69
- // it is question wording ("what is ", "compute ") to lift clear of.
70
- // With no prefix (s.i === 0) the span is judged by the ordinary
71
- // half-dominance rule below, which already correctly keeps a short
72
- // trailing glue byte ("2+2." → "4.", the span dominates a 4-byte query).
73
76
  if (s.computed && s.i > 0)
74
- return s.bytes;
77
+ return [s];
75
78
  if (dominates(s.j - s.i, queryLen)) {
76
- return concatBytes(segs.filter((_, k) => !restated[k]).map((x) => x.bytes));
79
+ return segs.filter((_, k) => !restated[k]);
77
80
  }
78
- return s.bytes;
81
+ return [s];
79
82
  }
80
83
  const lo = recognised[0];
81
84
  const hi = recognised[recognised.length - 1];
82
- return concatBytes(segs.slice(lo, hi + 1).filter((_, k) => !restated[lo + k]).map((x) => x.bytes));
85
+ return segs.slice(lo, hi + 1).filter((_, k) => !restated[lo + k]);
86
+ }
87
+ /** The SCAFFOLDING byte count of a lifted answer: how many of its bytes come
88
+ * from spans nothing recognised (see {@link liftAnswerParts}).
89
+ *
90
+ * ONLY RUNS OF AT LEAST ONE RIVER WINDOW COUNT. Not all carried-through
91
+ * bytes are a failure to explain: a period, a question mark, the space
92
+ * between two fused topics are GLUE — they belong to the answer's surface,
93
+ * and dropping them to look better-derived would be a worse answer, not a
94
+ * more honest one. A substantive phrase the derivation never explained
95
+ * ("famous for") is a different claim entirely.
96
+ *
97
+ * W is the line between them, and it is the same line the rest of the mind
98
+ * already draws: below one river window byte overlap is chance, not evidence
99
+ * (see identityBar, the bridge's attestedQ, and recognition's site floor).
100
+ * Counting every scaffolding byte instead — which is what this did first —
101
+ * made punctuation preservation lose a tie it should win, and test/00's
102
+ * "period preserved" / "question mark preserved" caught it immediately. */
103
+ export function liftedScaffolding(segs, queryLen, query, W) {
104
+ // MEASURED PER CONTIGUOUS RUN, not per span. A PASS span is one BYTE — the
105
+ // cover charges unrecognised bytes individually — so asking whether a single
106
+ // span reaches W would find no run ever, whatever the query. " famous for"
107
+ // arrives as eleven one-byte spans in a row and is one eleven-byte run.
108
+ let n = 0;
109
+ let run = 0;
110
+ const close = () => {
111
+ if (run >= W)
112
+ n += run;
113
+ run = 0;
114
+ };
115
+ for (const s of liftAnswerParts(segs, queryLen, query, W)) {
116
+ if (s.rec)
117
+ close();
118
+ else
119
+ run += s.bytes.length;
120
+ }
121
+ close();
122
+ return n;
123
+ }
124
+ export function liftAnswer(segs, queryLen, query, W) {
125
+ // ONE selection rule, in {@link liftAnswerParts} — this is its join. The
126
+ // two used to be separate copies of the same lo/hi/restated reasoning, which
127
+ // is exactly how an answer and the accounting OF that answer drift apart.
128
+ const parts = liftAnswerParts(segs, queryLen, query, W);
129
+ if (parts.length === 0)
130
+ return null;
131
+ return concatBytes(parts.map((x) => x.bytes));
83
132
  }
84
133
  /** The CHANGED NODES of a freshly-perceived `tree` against the node ids a previous
85
134
  * tracked deposit interned (`prevSeen`). */
@@ -152,6 +152,10 @@ export interface Store {
152
152
  contentLen(id: NodeId, cap?: number): number;
153
153
  findLeaf(bytes: Uint8Array): NodeId | null;
154
154
  findBranch(kids: NodeId[]): NodeId | null;
155
+ /** {@link findBranch} for a run of single-byte leaves, addressed by the raw
156
+ * bytes — the allocation-free probe span scanners use. Optional: a store
157
+ * without it is simply probed through `findBranch`. */
158
+ findFlatBranch?(bytes: Uint8Array): NodeId | null;
155
159
  /** The branch nodes that list `id` among their children — the reverse of
156
160
  * `get(id).kids`. Lets the structural DAG be climbed upward, from a
157
161
  * recognised fragment to the larger learned forms that contain it. */
@@ -585,6 +589,23 @@ export declare abstract class AbstractStore implements Store {
585
589
  private _prefix;
586
590
  contentLen(id: NodeId, cap?: number): number;
587
591
  findLeaf(bytes: Uint8Array): NodeId | null;
592
+ /** {@link findBranch} for a run of SINGLE-BYTE leaves, addressed by the
593
+ * bytes themselves — no kid array, no key string, no copy.
594
+ *
595
+ * A flat branch stores its children as {@link flatKidsBytes}, and that
596
+ * encoding is the identity on single-byte leaves: kid id −(b+1) IS byte b.
597
+ * So for such a run the kid array and the byte span are the same object in
598
+ * two spellings, and `findBranch(leafIds.slice(i, j))` and this call are
599
+ * the same lookup — except that the array path allocates the slice, then
600
+ * `kids.join(",")`, then the flat bytes, all O(span), for a probe whose
601
+ * answer is usually "no". The bloom filter behind `_dbFindBranchByLeaf`
602
+ * answers most of those with no I/O at all, so the allocations dominated.
603
+ *
604
+ * Pass a subarray: it is a view, so a caller scanning spans of a query
605
+ * allocates nothing per probe. Deliberately NOT memoized — its callers
606
+ * probe many spans that miss, and a key string per probe is the cost this
607
+ * exists to remove. */
608
+ findFlatBranch(bytes: Uint8Array): NodeId | null;
588
609
  findBranch(kids: NodeId[]): NodeId | null;
589
610
  parents(id: NodeId): NodeId[];
590
611
  parentsFirst(id: NodeId, limit: number): NodeId[];
package/dist/src/store.js CHANGED
@@ -770,6 +770,27 @@ export class AbstractStore {
770
770
  this._leafKey.set(key, id);
771
771
  return id;
772
772
  }
773
+ /** {@link findBranch} for a run of SINGLE-BYTE leaves, addressed by the
774
+ * bytes themselves — no kid array, no key string, no copy.
775
+ *
776
+ * A flat branch stores its children as {@link flatKidsBytes}, and that
777
+ * encoding is the identity on single-byte leaves: kid id −(b+1) IS byte b.
778
+ * So for such a run the kid array and the byte span are the same object in
779
+ * two spellings, and `findBranch(leafIds.slice(i, j))` and this call are
780
+ * the same lookup — except that the array path allocates the slice, then
781
+ * `kids.join(",")`, then the flat bytes, all O(span), for a probe whose
782
+ * answer is usually "no". The bloom filter behind `_dbFindBranchByLeaf`
783
+ * answers most of those with no I/O at all, so the allocations dominated.
784
+ *
785
+ * Pass a subarray: it is a view, so a caller scanning spans of a query
786
+ * allocates nothing per probe. Deliberately NOT memoized — its callers
787
+ * probe many spans that miss, and a key string per probe is the cost this
788
+ * exists to remove. */
789
+ findFlatBranch(bytes) {
790
+ if (this.meter)
791
+ this.meter.branchLookups++;
792
+ return this._dbFindBranchByLeaf(hashOf(bytes), bytes);
793
+ }
773
794
  findBranch(kids) {
774
795
  if (this.meter)
775
796
  this.meter.branchLookups++;
@@ -709,7 +709,18 @@ const isEpisode = (it: TrainingItem): it is Episode => typeof it !== "string";
709
709
  /** Build the accumulated-context episodes of a turn sequence: each successive
710
710
  * turn is the continuation of ALL the turns before it joined together. This is
711
711
  * the same cumulative-context shape a multi-turn conversation deposits, so the
712
- * store learns to continue a growing context. */
712
+ * store learns to continue a growing context.
713
+ *
714
+ * The "\n" below is a CORPUS choice, not a protocol. oasst2 turns are
715
+ * paragraphs, and reading them back with the newlines kept is how this corpus
716
+ * reads naturally; a different corpus may join with nothing, and
717
+ * test/13-conversation.test.mjs does exactly that. Neither has to match the
718
+ * other, because Sema never scans content for turn boundaries — those are
719
+ * offsets the Conversation API carries beside the bytes (see Mind.addTurn's
720
+ * "ON SEPARATORS" note). The newline here is simply part of the text this
721
+ * store learnt, so anything replaying this corpus feeds it back as part of
722
+ * the turn: `addTurn(conv, "\n" + turnText)`. It is not a convention the
723
+ * engine, the API, or the tests have to agree on. */
713
724
  function accumulate(turns: string[]): Episode[] {
714
725
  const out: Episode[] = [];
715
726
  for (let i = 1; i < turns.length; i++) {
@@ -894,9 +905,15 @@ export function bestOasstPath(root: OasstNode): OasstTurn[] {
894
905
  * turn experiences and local adjacent-pair facts are NOT emitted (they are
895
906
  * subsumed by it and would merely replicate the content).
896
907
  *
897
- * The walk is byte-for-byte the pattern proven in test/13-conversation.test.mjs
898
- * ("teachConversation"): each turn is the continuation of all prior turns joined
899
- * by "\n", with BARE turn text — NO "User:/Assistant:" labels. Roles already
908
+ * The walk is the pattern proven in test/13-conversation.test.mjs
909
+ * ("teachConversation"): each turn is the continuation of all prior turns,
910
+ * with BARE turn text — NO "User:/Assistant:" labels. The SHAPE is identical
911
+ * (cumulative context → next turn); the join string is not, and does not need
912
+ * to be — that file joins with nothing and this corpus joins with "\n" (see
913
+ * `accumulate`). Saying "byte-for-byte", as this comment used to, invites the
914
+ * reading that the two must agree on a separator. They must not agree,
915
+ * because there is nothing to agree about: turn boundaries are offsets, and
916
+ * the join string is just corpus text. Roles already
900
917
  * alternate by position in an oasst2 best-path (the root is a prompter), so a
901
918
  * label adds nothing the position does not, while a clean continuation matches
902
919
  * the test's recall (predictNext queries bare prior turns) and lets a turn share
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hviana/sema",
3
- "version": "0.4.7",
3
+ "version": "0.5.0",
4
4
  "description": "Sema: a non-parametric, instance-based reasoning system.",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",
package/src/canon.ts CHANGED
@@ -63,3 +63,31 @@ export function canonHash(key: Uint8Array): number {
63
63
  }
64
64
  return h >>> 0;
65
65
  }
66
+
67
+ /** The span of `bytes` between its first and last non-whitespace byte — the
68
+ * QUESTION, with the caller's edge spacing dropped. Returns a subarray (no
69
+ * copy), and the original when there is nothing to trim.
70
+ *
71
+ * THIS LIVES HERE, not in the core byte utilities, for the reason stated at
72
+ * the top of this file: "nothing in the store or the mind's core knows what
73
+ * 'case' or 'whitespace' is". Edge spacing is a TEXT fact — for a binary or
74
+ * grid modality 0x20 is content, not presentation — so it belongs beside the
75
+ * text canonicalizer, is injected on the same modality test, and never leaks
76
+ * into a mechanism. A modality that supplies its own canon supplies its own
77
+ * reading of "edge" too, or none.
78
+ *
79
+ * Why trimming is sound HERE when {@link textCanon} deliberately refuses it:
80
+ * canon preserves edge whitespace because the hazard is a recognised SUB-span
81
+ * swallowing the boundary byte that separates it from its neighbour (observed:
82
+ * "ice " matching the stored "ice"). At the outer edges of a WHOLE input
83
+ * there is no neighbour — nothing precedes byte 0, nothing follows the last
84
+ * byte — so that hazard cannot arise, and only there. */
85
+ export function textEdgeTrim(bytes: Uint8Array): Uint8Array {
86
+ const space = (b: number) =>
87
+ b === 0x20 || b === 0x09 || b === 0x0a || b === 0x0d;
88
+ let from = 0;
89
+ let to = bytes.length;
90
+ while (from < to && space(bytes[from])) from++;
91
+ while (to > from && space(bytes[to - 1])) to--;
92
+ return from === 0 && to === bytes.length ? bytes : bytes.subarray(from, to);
93
+ }