@hviana/sema 0.6.0 → 0.7.2

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 (42) hide show
  1. package/.github/workflows/release.yml +80 -0
  2. package/AGENTS.md +53 -9
  3. package/HOW_IT_WORKS.md +17 -16
  4. package/dist/src/meter.d.ts +14 -4
  5. package/dist/src/meter.js +27 -3
  6. package/dist/src/mind/attention.js +22 -20
  7. package/dist/src/mind/graph-search.d.ts +43 -9
  8. package/dist/src/mind/graph-search.js +82 -15
  9. package/dist/src/mind/junction.d.ts +13 -0
  10. package/dist/src/mind/junction.js +26 -1
  11. package/dist/src/mind/mechanisms/cover.js +23 -2
  12. package/dist/src/mind/mechanisms/prefix-completion.d.ts +2 -1
  13. package/dist/src/mind/mechanisms/prefix-completion.js +40 -20
  14. package/dist/src/mind/mechanisms/recall.js +8 -4
  15. package/dist/src/mind/pipeline-mechanism.d.ts +0 -24
  16. package/dist/src/mind/pipeline-mechanism.js +13 -36
  17. package/dist/src/mind/pipeline.d.ts +24 -0
  18. package/dist/src/mind/pipeline.js +71 -5
  19. package/dist/src/mind/recognition.d.ts +15 -1
  20. package/dist/src/mind/recognition.js +15 -1
  21. package/dist/src/mind/resonance.js +54 -12
  22. package/dist/src/store.js +22 -1
  23. package/jsr.json +1 -1
  24. package/package.json +7 -2
  25. package/src/meter.ts +27 -4
  26. package/src/mind/attention.ts +22 -19
  27. package/src/mind/graph-search.ts +93 -16
  28. package/src/mind/junction.ts +25 -1
  29. package/src/mind/mechanisms/cover.ts +23 -4
  30. package/src/mind/mechanisms/prefix-completion.ts +40 -20
  31. package/src/mind/mechanisms/recall.ts +8 -4
  32. package/src/mind/pipeline-mechanism.ts +13 -42
  33. package/src/mind/pipeline.ts +106 -5
  34. package/src/mind/recognition.ts +19 -2
  35. package/src/mind/resonance.ts +84 -49
  36. package/src/store.ts +21 -1
  37. package/test/89-completion-recursion.test.mjs +230 -0
  38. package/test/90-connector-read-cap.test.mjs +130 -0
  39. package/test/91-branch-bytes-cache.test.mjs +152 -0
  40. package/test/93-regime-prediction.test.mjs +148 -0
  41. package/test/94-cross-region-budget.test.mjs +67 -0
  42. package/test/95-wide-resonance-removed.test.mjs +109 -0
@@ -0,0 +1,80 @@
1
+ name: release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+ workflow_dispatch:
7
+ inputs:
8
+ version:
9
+ description: version to release, e.g. 0.6.1
10
+ type: string
11
+ required: true
12
+
13
+ permissions:
14
+ # Trusted publishing (OIDC): GitHub mints a short-lived identity token that
15
+ # npm and JSR both exchange against the trusted publisher on the package.
16
+ # No registry token is stored anywhere.
17
+ id-token: write
18
+ # The release path bumps package.json + jsr.json, commits, and pushes the tag.
19
+ contents: write
20
+
21
+ jobs:
22
+ publish:
23
+ runs-on: ubuntu-latest
24
+ steps:
25
+ - uses: actions/checkout@v4
26
+
27
+ - name: bump version and tag
28
+ if: github.event_name == 'workflow_dispatch'
29
+ env:
30
+ VERSION: ${{ inputs.version }}
31
+ run: |
32
+ git config user.name "github-actions[bot]"
33
+ git config user.email "github-actions[bot]@users.noreply.github.com"
34
+ current="$(node -p "require('./package.json').version")"
35
+ if [ "$current" != "$VERSION" ]; then
36
+ npm version "$VERSION" --no-git-tag-version
37
+ node -e 'const fs=require("fs");const p=JSON.parse(fs.readFileSync("jsr.json","utf8"));p.version=process.env.VERSION;fs.writeFileSync("jsr.json",JSON.stringify(p,null,2)+"\n")'
38
+ git add package.json jsr.json
39
+ git commit -m "$VERSION"
40
+ git tag -a "v$VERSION" -m "v$VERSION"
41
+ git push origin HEAD --follow-tags
42
+ fi
43
+
44
+ # The two manifests must not drift: a dual npm+JSR publish ships the SAME
45
+ # version to both, so a mismatch here would tag one and ship another.
46
+ - name: confirm package.json and jsr.json agree on version
47
+ run: |
48
+ pkg="$(node -p "require('./package.json').version")"
49
+ jsr="$(node -p "require('./jsr.json').version")"
50
+ if [ "$pkg" != "$jsr" ]; then
51
+ echo "package.json version $pkg != jsr.json version $jsr" >&2
52
+ exit 1
53
+ fi
54
+ echo "publishing version $pkg"
55
+
56
+ - uses: actions/setup-node@v4
57
+ with:
58
+ node-version: "24"
59
+ registry-url: https://registry.npmjs.org
60
+
61
+ - name: install
62
+ run: npm install
63
+
64
+ # Trusted publishing (OIDC) needs npm >= 11.5.1; Node 24 bundles an older npm.
65
+ - name: update npm for trusted publishing
66
+ run: npm install -g npm@latest
67
+
68
+ # The same run publishes: a tag pushed with GITHUB_TOKEN does NOT
69
+ # re-trigger workflows, so the publish cannot depend on a second run.
70
+ # prepublishOnly (npm test) verifies and builds dist/ before packing.
71
+ # dist/ is gitignored (so it stays off GitHub and JSR) but ships to npm
72
+ # because .npmignore deliberately omits the `dist/` entry.
73
+ - name: publish to npm
74
+ run: npm publish --access public
75
+
76
+ # JSR publishes the SOURCE (jsr.json exports → src/index.ts), not dist/.
77
+ # dist/ is gitignored and JSR honours .gitignore, so it is excluded here
78
+ # with no --allow-dirty needed (the tree is clean after npm's build).
79
+ - name: publish to jsr
80
+ run: npx jsr publish
package/AGENTS.md CHANGED
@@ -455,8 +455,7 @@ Asking never writes, which is the only reason per-response memos are sound.
455
455
  eager fields (recognition, computed spans, guide, the evidence-breadth constant
456
456
  `k`) plus **lazily-cached methods** for expensive analyses (`attention()` — the
457
457
  consensus climb, `weave()`, `resonance()` — the response's ONE top-k
458
- content-index read, `wideResonance()` — the one WIDE candidate list every
459
- past-the-top-k mechanism reads, `frames()` — the frame/slot inventory,
458
+ content-index read, `frames()` — the frame/slot inventory,
460
459
  `spanShapedOf`/`spanShapedAll`, `queryWindows`, `queryResolved`, `windowsOf`,
461
460
  `reachMemo`) — each computed at most once, shared by mechanisms and
462
461
  post-grounding stages, and never computed if nobody asks. The async ones are
@@ -464,10 +463,13 @@ cached **by promise**, so a second caller awaits the first computation rather
464
463
  than starting another.
465
464
 
466
465
  Mind-level memos (`climbMemo`, `recogniseMemo`, `perceiveMemo`, `canonMemo`,
467
- `_resolvedSubtrees`, `_edgeChoice`, `_gistCache`) are created in
468
- `beginResponse()` and torn down in `endResponse()` — a new memo must be added to
469
- both. A conversation supplies its own maps for the first four, so they persist
470
- across turns.
466
+ `_resolvedSubtrees`) are assigned in `beginResponse()` and nulled in
467
+ `endResponse()` — a new per-response memo must be added to both. A conversation
468
+ supplies its own maps for the first four, so they persist across turns.
469
+ `_edgeChoice` is a Mind field CLEARED in `endResponse()` (not re-created).
470
+ `_gistCache` is a SESSION-lifetime Mind field (32 MB, `mind.ts`) never touched
471
+ by begin/endResponse: a node's bytes are immutable and perception is pure, so a
472
+ cached gist is valid for the store's lifetime.
471
473
 
472
474
  **Which memos a trace bypasses, and why the answer is "almost none".** Only
473
475
  `_edgeChoice` (via `guidedNext`) and `sharedReachMemo` are trace-bypassed —
@@ -546,9 +548,9 @@ answer was chosen, the meter says what it cost. Four contracts:
546
548
  so two runs are diffable and a work regression is visible without a
547
549
  stopwatch. Only `elapsedMs` and the phase millisecond totals are not.
548
550
  3. **Phases nest, and carry their own counter deltas.** `think` ⊃ `<mech>.run` ⊃
549
- `substitutionBridge` ⊃ `recall.exhaustiveResonate`. Inclusive, never summed —
550
- but each phase reports the work done inside it (`PhaseCost.counters`), which
551
- is what makes "which phase did those byte reads?" answerable at all.
551
+ `substitutionBridge`. Inclusive, never summed — but each phase reports the
552
+ work done inside it (`PhaseCost.counters`), which is what makes "which phase
553
+ did those byte reads?" answerable at all.
552
554
  4. **Count a logical operation once.** A recursive read (`bytesPrefix`
553
555
  descending a branch) is charged at the public entry point only — the private
554
556
  `_prefix` body is uncharged. Counting the recursion made one read of an
@@ -608,6 +610,48 @@ corruption via phrase-interior chunks) and the `couldGrow` liveness rule (O(N²)
608
610
  chart growth). When you fix a subtle bug, leave the constraint behind, not the
609
611
  story of the fix.
610
612
 
613
+ ### 2.17 Saturation — every walk decides, none drifts
614
+
615
+ Saturation is a first-class control, not a secondary nicety — and it is TWO
616
+ things, distinguished deliberately:
617
+
618
+ - a CAP — a derived bound (√N per read, √N·W per walk) that exists only to stop
619
+ magic constants (§2.2) — is a SAFETY NET. It bounds the walk when its question
620
+ never decides (a side too common to ever settle). Derived, never tuned; but it
621
+ is not itself a decision.
622
+ - a REAL saturation — a named, derived stop that DECIDES the walk's question and
623
+ terminates the moment it is decided. The cap remains as the backstop; the
624
+ saturation ends the walk. A walk with only a cap drifts to the cap every time;
625
+ a walk with a real saturation stops where the answer is already known.
626
+
627
+ `edgeAncestors` is the model (EXPAND-UNTIL-DECIDED): a reach is consumed either
628
+ as a VOTE (needs `contextsReached` exactly, only while ≤ √N) or as an ABSTENTION
629
+ (`saturated`), so it stops at the FIRST of its five named stops and no consumer
630
+ reads a saturated reach's roots or counts. `pivotInto`'s candidate scan is the
631
+ second: "longest valid wins" is DECIDED at the first valid candidate in
632
+ descending length, so it reads one winner's bytes, never every shorter
633
+ candidate. Saturation is a DECISION about the answer — never a cache, never a
634
+ budget.
635
+
636
+ The junction walk's per-node hub guards are real per-node saturations; its
637
+ `√N·W` budget is the NET, not a saturation. REFUTED (test/16 bridge synthesis,
638
+ test/34 n-ary binding): a "stop once one side's cone is exhausted" early stop is
639
+ WRONG, in both a hub-guarded and a hub-flagged form. The junction test is a BYTE
640
+ containment over the UNION of the two cones, and a junction can be structurally
641
+ reachable from only ONE side — the side whose seed is a FOLD sub-node of the
642
+ container. test/16: "cold or hot" is reached from the window "cold", but the
643
+ 3-byte answer "hot" is not a 4-byte window of it, so "hot"'s cone empties after
644
+ one pop while the junction still lies ahead in "cold"'s cone. "One cone
645
+ exhausted" therefore never proves "no junction left", and the budget stays the
646
+ net that backs the per-node saturations.
647
+
648
+ _Follow it:_ when a new walk measures commonality against the corpus, name its
649
+ saturation condition — the answer it may stop producing — beside its read cap.
650
+ No code may traverse the inference uncontrolled from the corpus, nor lack the
651
+ saturation its question admits. A cap without a saturation is a drift, and a
652
+ drifting walk is a bug, not a tuning choice; do not mask it with a cache (§2.12)
653
+ — a cache hides a drift on a warm store, saturation removes it.
654
+
611
655
  ---
612
656
 
613
657
  ## 3. Where things live
package/HOW_IT_WORKS.md CHANGED
@@ -4434,16 +4434,19 @@ before the conversion. Derived from the existing bars; never tuned.
4434
4434
  ### 21.5 The refusal path — the substitution bridge, before silence
4435
4435
 
4436
4436
  Everything geometric has now failed. One tier remains, making a **structural**
4437
- claim about the query that resonance cannot state. It reads the response's
4438
- **wide candidate list** (`Precomputed.wideResonance`, §14.5) — the ranked hits,
4439
- widened to an exhaustive index scan only when the top hit clears the concept
4440
- threshold. When the query gist has no concept-level match to anything stored, an
4441
- exhaustive scan would only score more vectors below the bar (profiled at 38–40K
4442
- vectors scored per refusing query on a 325K-context store, costing 44% of
4443
- think). Whether the gist ranks _anything_ at concept level is the discriminator
4444
- — corpus size never was. The list is shared response-wide, so whichever
4437
+ claim about the query that resonance cannot state. Its proposal source is the
4438
+ response's **one top-k read** (`Precomputed.resonance`, §14.5) — the same ranked
4439
+ list recall's earlier tiers already consulted, shared response-wide so whichever
4445
4440
  mechanism first-touches it pays once and every later reader is free.
4446
4441
 
4442
+ It used to be a **wide** list, widened to an exhaustive index scan whenever the
4443
+ top hit cleared the concept threshold. That was removed: the bridge's own
4444
+ candidate cap is `2 · recallQueryK`, so the top-k already IS everything it can
4445
+ consume, and every proposal is byte-verified downstream (§4.3) — an exhaustive
4446
+ scan bought an O(k) need at O(index) cost (profiled: 244K vectors scored per
4447
+ refusing query, ~1.5 s, every answer byte-identical to the top-k read). Test/95
4448
+ pins its absence.
4449
+
4447
4450
  #### The substitution bridge
4448
4451
 
4449
4452
  **The gap.** A query phrased through a near-synonym of a trained word ("Name the
@@ -5288,16 +5291,14 @@ recallByResonance(query, pre):
5288
5291
  return { bytes: g, accounted: nothing, moves: STEP }
5289
5292
 
5290
5293
  # ── the REFUSAL PATH ─────────────────────────────────────────────
5291
- # pre.wideResonance() — the response's ONE wide candidate list, shared
5292
- # by every mechanism that must look past the top-k:
5293
- # hits[0].score CONCEPT_BAR
5294
- # ? exhaustive resonate(gistOf(query), hubBound) # ids only
5295
- # : hits # the gist ranks nothing at concept
5296
- # # level, so a wider scan says nothing
5294
+ # pre.resonance() — the response's ONE top-k read, already paid for by
5295
+ # the tiers above. The bridge caps its own candidates at 2·recallQueryK,
5296
+ # so the top-k is exactly the budget it can consume; there is no wider
5297
+ # list, and every proposal is byte-verified below.
5297
5298
 
5298
5299
  # 3b. substitution / identity bridge
5299
- bridged ≔ substitutionBridge(query, pre.wideResonance)
5300
- # anchors: rarest query windows → edgeAncestors, plus wideIds
5300
+ bridged ≔ substitutionBridge(query, ids(pre.resonance))
5301
+ # anchors: rarest query windows → edgeAncestors, plus those ids
5301
5302
  # align byte-for-byte; a mismatch substitutes only under
5302
5303
  # CORROBORATION ∧ GRADED IDENTITY ∧ RAW BALANCE
5303
5304
  # accept when matched+substituted DOMINATES the query, every
@@ -4,7 +4,7 @@
4
4
  * PHASES NEST, AND ARE NOT DISJOINT. `think` contains every mechanism
5
5
  * phase; a mechanism's `floor` contains whatever shared analysis it
6
6
  * first-touched (`attention`, `weave`); `recall.run` contains
7
- * `substitutionBridge`, which contains `recall.exhaustiveResonate`. Read a
7
+ * `substitutionBridge`. Read a
8
8
  * phase as "wall-clock spent inside this, inclusive" — never sum them and
9
9
  * expect the total. `CostReport.elapsedMs` is the only whole.
10
10
  *
@@ -129,6 +129,13 @@ export declare class Meter {
129
129
  /** Nodes popped by those ascents, against their √N·W budget — the counter
130
130
  * that shows whether the walks are deciding early or burning the budget. */
131
131
  junctionPops: number;
132
+ /** Ascents that ended by EXHAUSTING the expansion budget rather than by
133
+ * deciding — the walk abstained and the caller silently fell through to a
134
+ * lower tier of the ladder (§2.13: a degradation nothing else reports).
135
+ * It rises the moment a SHARED budget is drained by an earlier walk, which
136
+ * is what makes "this tier answered nothing" distinguishable from "this
137
+ * tier never got to look". */
138
+ junctionBudgetExhausted: number;
132
139
  /** Arbitrary byte spans whose distributional company was VSA-bundled from
133
140
  * existing episode halos. */
134
141
  spanHalos: number;
@@ -148,9 +155,6 @@ export declare class Meter {
148
155
  mechanismRuns: number;
149
156
  /** Candidates the decider weighed. */
150
157
  candidates: number;
151
- /** Candidates refused before the competition for explaining less than 1/W
152
- * of the query — the honesty-density floor (see pipeline.ts `consider`). */
153
- thinRejects: number;
154
158
  private readonly _phases;
155
159
  private readonly _t0;
156
160
  /** Every work counter's current value, by name — the snapshot `time`
@@ -159,6 +163,12 @@ export declare class Meter {
159
163
  /** Charge `ms`, one call, and a counter delta to a named phase.
160
164
  * Insertion-ordered, so a report reads in execution order. */
161
165
  charge(phase: string, ms: number, delta?: Record<string, number>): void;
166
+ /** Time one SYNCHRONOUS phase. The sync/async seam (§2.10) is a real
167
+ * contract — perception, recognition and the graph search are synchronous —
168
+ * so a synchronous layer must not be wrapped in `time`'s promise just to be
169
+ * measured: that would make the profiled path await where the unprofiled
170
+ * one does not, and a meter never changes what a layer computes. */
171
+ timeSync<T>(phase: string, fn: () => T): T;
162
172
  /** Time one async phase and attribute the work done inside it. Returns
163
173
  * the awaited value untouched — a meter never changes what a layer
164
174
  * computes, only what is known about it. */
package/dist/src/meter.js CHANGED
@@ -122,6 +122,13 @@ export class Meter {
122
122
  /** Nodes popped by those ascents, against their √N·W budget — the counter
123
123
  * that shows whether the walks are deciding early or burning the budget. */
124
124
  junctionPops = 0;
125
+ /** Ascents that ended by EXHAUSTING the expansion budget rather than by
126
+ * deciding — the walk abstained and the caller silently fell through to a
127
+ * lower tier of the ladder (§2.13: a degradation nothing else reports).
128
+ * It rises the moment a SHARED budget is drained by an earlier walk, which
129
+ * is what makes "this tier answered nothing" distinguishable from "this
130
+ * tier never got to look". */
131
+ junctionBudgetExhausted = 0;
125
132
  /** Arbitrary byte spans whose distributional company was VSA-bundled from
126
133
  * existing episode halos. */
127
134
  spanHalos = 0;
@@ -142,9 +149,6 @@ export class Meter {
142
149
  mechanismRuns = 0;
143
150
  /** Candidates the decider weighed. */
144
151
  candidates = 0;
145
- /** Candidates refused before the competition for explaining less than 1/W
146
- * of the query — the honesty-density floor (see pipeline.ts `consider`). */
147
- thinRejects = 0;
148
152
  // ── Phases ──────────────────────────────────────────────────────────────
149
153
  _phases = new Map();
150
154
  _t0 = performance.now();
@@ -176,6 +180,26 @@ export class Meter {
176
180
  }
177
181
  }
178
182
  }
183
+ /** Time one SYNCHRONOUS phase. The sync/async seam (§2.10) is a real
184
+ * contract — perception, recognition and the graph search are synchronous —
185
+ * so a synchronous layer must not be wrapped in `time`'s promise just to be
186
+ * measured: that would make the profiled path await where the unprofiled
187
+ * one does not, and a meter never changes what a layer computes. */
188
+ timeSync(phase, fn) {
189
+ const before = this.snapshot();
190
+ const t = performance.now();
191
+ try {
192
+ return fn();
193
+ }
194
+ finally {
195
+ const ms = performance.now() - t;
196
+ const after = this.snapshot();
197
+ const delta = {};
198
+ for (const k of Object.keys(after))
199
+ delta[k] = after[k] - before[k];
200
+ this.charge(phase, ms, delta);
201
+ }
202
+ }
179
203
  /** Time one async phase and attribute the work done inside it. Returns
180
204
  * the awaited value untouched — a meter never changes what a layer
181
205
  * computes, only what is known about it. */
@@ -12,7 +12,7 @@ import { composeStructuralGist, consensusFloor, dominates, estimatorNoise, } fro
12
12
  import { foldTree, gistOf, latin1Key, perceive, read, resolve, } from "./primitives.js";
13
13
  import { recognise } from "./recognition.js";
14
14
  import { leafIdRun } from "./canonical.js";
15
- import { corpusN, edgeAncestors, hubBound, sharedReachMemo, } from "./traverse.js";
15
+ import { atomIsHub, corpusN, edgeAncestors, hubBound, sharedReachMemo, } from "./traverse.js";
16
16
  import { cachedRead, junctionContainersFrom, junctionSeeds, junctionSynonyms, loadJunctionSynonymSides, walkCache, } from "./junction.js";
17
17
  import { indexOf } from "../bytes.js";
18
18
  import { rItem, rNode, traceDerivation } from "./trace.js";
@@ -1848,26 +1848,28 @@ async function crossRegionVotes(ctx, query, regions, rvs, k, N, reachMemo, td) {
1848
1848
  // the same container (or a sub-container of it) twice.
1849
1849
  const consumed = new Set();
1850
1850
  let probes = 0;
1851
- // Once atoms themselves are hubs (N > W²), the cross-region analysis gets
1852
- // one k·W walk allowance per evidence tier. Without a shared allowance,
1853
- // each of k candidate pairs spends the full corpus-derived budget and a
1854
- // cumulative dialogue multiplies bounded work into tens of seconds. Small
1855
- // corpora retain exhaustive exact traversal: below this same scale the
1856
- // budget would be smaller than the structures the tests deliberately build.
1851
+ // When atoms themselves are hubs (atomIsHub a single byte reaches ≥ √N
1852
+ // contexts, §2.8's own predicate), the corpus is large enough that the
1853
+ // cross-region junction walks are dominated by the drift through common
1854
+ // content's ancestry. Each of k candidate pairs otherwise spends its own
1855
+ // √N·W budget (profiled: 160,210 junction pops, 31% of think at
1856
+ // N = 325,608), and a cumulative dialogue multiplies bounded work into tens
1857
+ // of seconds. The structural walk is therefore given ONE k·W allowance per
1858
+ // evidence tier, shared across every pair — k pairs × W phrase-scale levels,
1859
+ // the minimal exact check; a pair whose container is not reached within it
1860
+ // falls through to the resonance tier (the ANN proposes what the shallow
1861
+ // walk no longer exhaustively scans, §2.3).
1857
1862
  //
1858
- // MEASURED 2026-07-29, NOT YET RESOLVED. This gate never engages at real
1859
- // scale: on the trained store N = 325,608 with k = 24 and W = 4, so the
1860
- // threshold is 96³ = 884,736 and a third of a million contexts still runs
1861
- // unbudgeted at hubBound·W = 2,280 pops PER PAIR 160,210 junction pops,
1862
- // 5.9s, 31% of think. Sharing one hubBound·W allowance across all pairs
1863
- // instead cuts that to 22,418 pops and 2.6s (think −19%), but is measurably
1864
- // too tight below ~10³ contexts: test/36 (N = 8, budget 8) loses the
1865
- // `red circle` binding root and test/14 (N = 120, budget 40) recalls 39/40.
1866
- // The sharing is the right shape; hubBound·W is the wrong size for it, and
1867
- // fitting a size to those two points would repeat the mistake the cube
1868
- // already makes — pricing the gate on the synthetic corpora.
1869
- const marketScale = k * ctx.space.maxGroup;
1870
- const corpusScale = N > marketScale ** 3;
1863
+ // Below atomIsHub the store is small and atoms still discriminate, so the
1864
+ // walks keep exhaustive exact traversal (per-walk √N·W) the shared budget
1865
+ // would otherwise be smaller than the structures the tests deliberately
1866
+ // build. The gate is the SAME derived predicate the climb already uses for
1867
+ // byte atoms, not a separate corpus-size knob: an earlier `N > (k·W)³` cube
1868
+ // never engaged at real scale (96³ = 884,736 > 325,608), and a "share one
1869
+ // √N·W" experiment was too tight below ~10³ contexts (test/36, test/14)
1870
+ // both are the same mistake of pricing the gate on corpus size instead of on
1871
+ // the atom-hub scale.
1872
+ const corpusScale = atomIsHub(ctx, N);
1871
1873
  const exactBudget = corpusScale ? { n: k * ctx.space.maxGroup } : undefined;
1872
1874
  const synonymBudget = corpusScale ? { n: k * ctx.space.maxGroup } : undefined;
1873
1875
  for (let a = 0; a < cand.length && probes < k; a++) {
@@ -66,6 +66,13 @@ export type GItem = {
66
66
  * because subtraction is more "the point" of its query). See
67
67
  * {@link liftAnswer}. */
68
68
  computed?: boolean;
69
+ /** Set on the out emitted at a chain's GENUINE FIXPOINT — the one span kind a
70
+ * recursive re-cover ({@link GraphSearch.recompleteNode}) may deepen. The
71
+ * re-cover does NOT run here: this marks the span as eligible, and
72
+ * {@link GraphSearch.deepen} runs it afterwards on the spans the lightest
73
+ * derivation actually CHOSE. Part of {@link key}, because it decides
74
+ * whether the span's final bytes may still change. */
75
+ fix?: boolean;
69
76
  };
70
77
  export declare const STEP = 1;
71
78
  export declare const CONCEPT = 10;
@@ -87,6 +94,10 @@ export interface Seg {
87
94
  /** See the `computed` field of the "out" {@link GItem} — set only for an
88
95
  * extension's derived value, never a genuinely recognised learned form. */
89
96
  computed?: boolean;
97
+ /** See the `fix` field of the "out" {@link GItem} — this span ended a chain at
98
+ * a genuine fixpoint, so it is the one span kind a recursive re-cover may
99
+ * still deepen. Consumed by {@link GraphSearch.deepen}. */
100
+ fix?: boolean;
90
101
  }
91
102
  /** One rule application inside the cover's lightest derivation — the FINEST
92
103
  * grain of Sema's core reasoning, one node of the adapted A*LD proof tree. `move`
@@ -192,6 +203,20 @@ export declare class GraphSearch {
192
203
  * decomposes, two recomposes, any mix — and stops only when it reaches a node
193
204
  * that leads nowhere new, never at an arbitrary count. */
194
205
  private solve;
206
+ /** Re-cover the CHOSEN fixpoint spans, in place.
207
+ *
208
+ * Completion is still "cover, recursively" — it just runs on the answer
209
+ * instead of on the exploration. A cover chooses O(segs) spans, so a level
210
+ * pays O(answer) re-covers however densely the corpus interconnects the forms
211
+ * the search passed through on the way. That is the bound
212
+ * {@link recompleteNode}'s contract always claimed and, running per fixpoint
213
+ * REACHED, never had.
214
+ *
215
+ * Deepening cannot change which cover won: the derivation is already final
216
+ * and every span keeps its i..j and its cost. It only replaces a chosen
217
+ * span's bytes with the deeper learnt form they rewrite to — what the
218
+ * recursion was always for. */
219
+ private deepen;
195
220
  /** The weighted deduction system the graph exploration solves (the four
196
221
  * reductions of adapted A*LD live in {@link lightestDerivation}; this only states the
197
222
  * items, axioms, goal, and rules — see {@link GItem} for the item kinds).
@@ -253,20 +278,29 @@ export declare class GraphSearch {
253
278
  * ({@link resolve}) — the graph itself gates against re-expanding a contained
254
279
  * form ("ice is cold" ⊅→ "ice is cold is cold").
255
280
  *
256
- * Termination is INTRINSIC, not a depth limit: a node already on the
257
- * completion stack ({@link recompleteOpen}) is not re-entered a self-
258
- * referential recomposition is a cycle that can yield nothing new, so it
259
- * stops there, exactly as {@link completeForward} stops on a revisited edge.
260
- * Distinct node ids are finite and each finished completion is memoised, so a
261
- * legitimate chain runs as deep as the graph licenses and no further. */
281
+ * Termination is STRUCTURAL: a produced node is re-covered once, never inside
282
+ * another re-cover (see the guard below), so one cover pays at most one
283
+ * nested {@link solve} per distinct produced node it actually reaches, and
284
+ * {@link recompleteMemo} collapses a repeat to nothing.
285
+ *
286
+ * This comment used to argue termination from "distinct node ids are finite
287
+ * and each finished completion is memoised". That is a bound of N — the one
288
+ * AGENTS §2.8 forbids — and it was load-bearing, not pedantic: nested, the
289
+ * recursion reached depth 331 and 9.1 GB on an 18.9M-node store for a 2-byte
290
+ * query and did not terminate, which is what killed a 5 h training run at its
291
+ * checkpoint recall. Guard: test/89-completion-recursion.test.mjs. */
262
292
  private recompleteNode;
263
293
  /** Per-cover memo of each produced node's completion (so the many terminal
264
294
  * outs of a long query re-cover each distinct node at most once); reset at the
265
295
  * top of {@link cover}. */
266
296
  private recompleteMemo;
267
- /** The nodes currently being re-completed — the recursion stack. A node in
268
- * this set is not re-entered, so a cyclic recomposition terminates naturally
269
- * (the same cycle guard {@link completeForward} uses), with no depth cap. */
297
+ /** The node currently being re-completed — the recursion stack, and so also
298
+ * the nesting depth. {@link recompleteNode} refuses to start while it is
299
+ * non-empty (one re-cover per produced node, never one inside another), which
300
+ * is what keeps a query's cost proportional to its answer rather than to the
301
+ * corpus; it therefore holds at most one id. A Set, not a flag, because it
302
+ * states WHICH node is open — the invariant a reader needs to check the
303
+ * guard, and what makes the old cycle-guard reading still hold. */
270
304
  private recompleteOpen;
271
305
  /** out(i,j,bytes,…): index it for the binary rules, then offer splicing a
272
306
  * learnt connector (the in-search bridge), splitting (at a sub-leaf form
@@ -64,6 +64,7 @@ function readCover(derivation) {
64
64
  rec: out.rec,
65
65
  node: out.node,
66
66
  computed: out.computed,
67
+ fix: out.fix,
67
68
  });
68
69
  }
69
70
  node = node.premises[0];
@@ -269,9 +270,32 @@ export class GraphSearch {
269
270
  onDerivation(readDerivation(derivation, substitutions !== undefined));
270
271
  }
271
272
  return derivation
272
- ? { segs: readCover(derivation), cost: derivation.cost }
273
+ ? { segs: this.deepen(readCover(derivation)), cost: derivation.cost }
273
274
  : null;
274
275
  }
276
+ /** Re-cover the CHOSEN fixpoint spans, in place.
277
+ *
278
+ * Completion is still "cover, recursively" — it just runs on the answer
279
+ * instead of on the exploration. A cover chooses O(segs) spans, so a level
280
+ * pays O(answer) re-covers however densely the corpus interconnects the forms
281
+ * the search passed through on the way. That is the bound
282
+ * {@link recompleteNode}'s contract always claimed and, running per fixpoint
283
+ * REACHED, never had.
284
+ *
285
+ * Deepening cannot change which cover won: the derivation is already final
286
+ * and every span keeps its i..j and its cost. It only replaces a chosen
287
+ * span's bytes with the deeper learnt form they rewrite to — what the
288
+ * recursion was always for. */
289
+ deepen(segs) {
290
+ for (const s of segs) {
291
+ if (!s.fix || s.node === undefined)
292
+ continue;
293
+ const deeper = this.recompleteNode(s.node);
294
+ if (deeper !== null)
295
+ s.bytes = deeper;
296
+ }
297
+ return segs;
298
+ }
275
299
  /** The weighted deduction system the graph exploration solves (the four
276
300
  * reductions of adapted A*LD live in {@link lightestDerivation}; this only states the
277
301
  * items, axioms, goal, and rules — see {@link GItem} for the item kinds).
@@ -319,7 +343,7 @@ export class GraphSearch {
319
343
  if (it.kind === "form") {
320
344
  return `f${it.i}.${it.j}.${it.node}.${it.via ? 1 : 0}.${it.rcmp ? 1 : 0}`;
321
345
  }
322
- return `o${it.i}.${it.j}.${it.cover ? 1 : 0}.${it.rec ? 1 : 0}.${it.node ?? -1}.${latin1(it.bytes)}`;
346
+ return `o${it.i}.${it.j}.${it.cover ? 1 : 0}.${it.rec ? 1 : 0}.${it.fix ? 1 : 0}.${it.node ?? -1}.${latin1(it.bytes)}`;
323
347
  },
324
348
  *axioms() {
325
349
  yield { item: { kind: "cover", p: 0 }, cost: 0 };
@@ -614,17 +638,29 @@ export class GraphSearch {
614
638
  // actual end, never per intermediate stop — so its cost tracks the
615
639
  // ANSWER's own structure, not how densely the corpus interconnects
616
640
  // the nodes passed through on the way there.
617
- const deeper = this.recompleteNode(it.node);
641
+ // MARK the fixpoint; do not re-cover it here. Re-covering at this point
642
+ // pays a full {@link recompleteNode} — a recognition of the node's whole
643
+ // bytes — for every fixpoint the exploration REACHES, and how many it
644
+ // reaches is set by how densely the corpus interconnects the forms passed
645
+ // through. Measured on an 18.9M-node store, a 2-byte query: 12,000
646
+ // re-covers inside ONE cover, folding 95,258 distinct spans, ~2.4 GB and
647
+ // climbing to a V8 fatal. The answer needs a handful.
648
+ //
649
+ // {@link deepen} runs it instead on the spans the lightest derivation
650
+ // CHOSE. The ladder is untouched — this out still costs 0, so a genuine
651
+ // fixpoint still beats any premature stop at the same depth, exactly as
652
+ // the ordering above states.
618
653
  yield {
619
654
  premises: [it],
620
655
  conclusion: {
621
656
  kind: "out",
622
657
  i: it.i,
623
658
  j: it.j,
624
- bytes: deeper ?? nodeBytes(it.node),
659
+ bytes: nodeBytes(it.node),
625
660
  cover: true,
626
661
  rec: true,
627
662
  node: it.node,
663
+ fix: true,
628
664
  },
629
665
  cost: 0,
630
666
  };
@@ -694,20 +730,47 @@ export class GraphSearch {
694
730
  * ({@link resolve}) — the graph itself gates against re-expanding a contained
695
731
  * form ("ice is cold" ⊅→ "ice is cold is cold").
696
732
  *
697
- * Termination is INTRINSIC, not a depth limit: a node already on the
698
- * completion stack ({@link recompleteOpen}) is not re-entered a self-
699
- * referential recomposition is a cycle that can yield nothing new, so it
700
- * stops there, exactly as {@link completeForward} stops on a revisited edge.
701
- * Distinct node ids are finite and each finished completion is memoised, so a
702
- * legitimate chain runs as deep as the graph licenses and no further. */
733
+ * Termination is STRUCTURAL: a produced node is re-covered once, never inside
734
+ * another re-cover (see the guard below), so one cover pays at most one
735
+ * nested {@link solve} per distinct produced node it actually reaches, and
736
+ * {@link recompleteMemo} collapses a repeat to nothing.
737
+ *
738
+ * This comment used to argue termination from "distinct node ids are finite
739
+ * and each finished completion is memoised". That is a bound of N — the one
740
+ * AGENTS §2.8 forbids — and it was load-bearing, not pedantic: nested, the
741
+ * recursion reached depth 331 and 9.1 GB on an 18.9M-node store for a 2-byte
742
+ * query and did not terminate, which is what killed a 5 h training run at its
743
+ * checkpoint recall. Guard: test/89-completion-recursion.test.mjs. */
703
744
  recompleteNode(node) {
704
745
  if (!this.host.recogniseSpan)
705
746
  return null;
706
747
  const memo = this.recompleteMemo;
707
748
  if (memo.has(node))
708
749
  return memo.get(node) ?? null;
709
- // Cycle guard: a node being completed must not recurse back into itself.
710
- if (this.recompleteOpen.has(node))
750
+ // ONE re-cover per produced node never a re-cover inside a re-cover.
751
+ //
752
+ // Re-covering is how a PRODUCED node's bytes enter the search at all: the
753
+ // cover machinery otherwise only ever sees the QUERY's spans. That is
754
+ // needed once. The alternation of decomposition and recomposition that
755
+ // follows — parts rewriting several times, siblings fusing, a recomposition
756
+ // feeding another — is the main search's own fuse/`rcmp` work, not this
757
+ // recursion's: 15-decomposition-gap §9–§12 all pass with this method
758
+ // disabled outright, and only §6 (the produced composite "p1 p2", whose
759
+ // bytes nothing else brings in) needs it.
760
+ //
761
+ // Nesting it was the defect. Each level is a full {@link solve} with its
762
+ // own agenda and chart, exploring from a node the answer never asked about,
763
+ // so per-query cost tracked how densely the corpus interconnects the forms
764
+ // passed through — the growth AGENTS §2.8 forbids. Measured on an
765
+ // 18.9M-node store: depth 331 and 9.1 GB for a 2-byte query, not
766
+ // terminating; and on the guard corpus every one of 125 nested re-covers
767
+ // was REJECTED by the resolve() gate below, expanding a 70-byte node into a
768
+ // 374-byte concatenation that names nothing. All of it was waste.
769
+ //
770
+ // `recompleteOpen` is that stack, so a non-empty stack means we are already
771
+ // inside one. This subsumes the old cycle guard: a node cannot recurse
772
+ // back into itself when nothing recurses at all.
773
+ if (this.recompleteOpen.size > 0)
711
774
  return null;
712
775
  // A leaf or single-child node has no parts to recompose; skip before the
713
776
  // costly recognition so a plain terminal answer pays nothing.
@@ -740,9 +803,13 @@ export class GraphSearch {
740
803
  * outs of a long query re-cover each distinct node at most once); reset at the
741
804
  * top of {@link cover}. */
742
805
  recompleteMemo = new Map();
743
- /** The nodes currently being re-completed — the recursion stack. A node in
744
- * this set is not re-entered, so a cyclic recomposition terminates naturally
745
- * (the same cycle guard {@link completeForward} uses), with no depth cap. */
806
+ /** The node currently being re-completed — the recursion stack, and so also
807
+ * the nesting depth. {@link recompleteNode} refuses to start while it is
808
+ * non-empty (one re-cover per produced node, never one inside another), which
809
+ * is what keeps a query's cost proportional to its answer rather than to the
810
+ * corpus; it therefore holds at most one id. A Set, not a flag, because it
811
+ * states WHICH node is open — the invariant a reader needs to check the
812
+ * guard, and what makes the old cycle-guard reading still hold. */
746
813
  recompleteOpen = new Set();
747
814
  /** out(i,j,bytes,…): index it for the binary rules, then offer splicing a
748
815
  * learnt connector (the in-search bridge), splitting (at a sub-leaf form