@hviana/sema 0.5.9 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/.github/workflows/release.yml +80 -0
  2. package/AGENTS.md +73 -13
  3. package/DATASETS.md +12 -11
  4. package/dist/example/train_base/cache.d.ts +35 -0
  5. package/dist/example/train_base/cache.js +211 -0
  6. package/dist/example/train_base/config.d.ts +21 -0
  7. package/dist/example/train_base/config.js +94 -0
  8. package/dist/example/train_base/corpora/aya.d.ts +19 -0
  9. package/dist/example/train_base/corpora/aya.js +76 -0
  10. package/dist/example/train_base/corpora/converted-parquet.d.ts +14 -0
  11. package/dist/example/train_base/corpora/converted-parquet.js +44 -0
  12. package/dist/example/train_base/corpora/genknow.d.ts +14 -0
  13. package/dist/example/train_base/corpora/genknow.js +83 -0
  14. package/dist/example/train_base/corpora/index.d.ts +29 -0
  15. package/dist/example/train_base/corpora/index.js +81 -0
  16. package/dist/example/train_base/corpora/massive.d.ts +7 -0
  17. package/dist/example/train_base/corpora/massive.js +98 -0
  18. package/dist/example/train_base/corpora/oasst2.d.ts +52 -0
  19. package/dist/example/train_base/corpora/oasst2.js +120 -0
  20. package/dist/example/train_base/corpora/smolsent.d.ts +23 -0
  21. package/dist/example/train_base/corpora/smolsent.js +156 -0
  22. package/dist/example/train_base/corpora/soda.d.ts +12 -0
  23. package/dist/example/train_base/corpora/soda.js +113 -0
  24. package/dist/example/train_base/corpora/taskmaster.d.ts +15 -0
  25. package/dist/example/train_base/corpora/taskmaster.js +144 -0
  26. package/dist/example/train_base/corpora/wiki2.d.ts +23 -0
  27. package/dist/example/train_base/corpora/wiki2.js +132 -0
  28. package/dist/example/train_base/corpus.d.ts +88 -0
  29. package/dist/example/train_base/corpus.js +65 -0
  30. package/dist/example/train_base/discovery.d.ts +48 -0
  31. package/dist/example/train_base/discovery.js +143 -0
  32. package/dist/example/train_base/http.d.ts +82 -0
  33. package/dist/example/train_base/http.js +219 -0
  34. package/dist/example/train_base/items.d.ts +46 -0
  35. package/dist/example/train_base/items.js +98 -0
  36. package/dist/example/train_base/main.d.ts +4 -0
  37. package/dist/example/train_base/main.js +207 -0
  38. package/dist/example/train_base/progress.d.ts +34 -0
  39. package/dist/example/train_base/progress.js +114 -0
  40. package/dist/example/train_base/readers.d.ts +125 -0
  41. package/dist/example/train_base/readers.js +391 -0
  42. package/dist/example/train_base/runtime.d.ts +115 -0
  43. package/dist/example/train_base/runtime.js +637 -0
  44. package/dist/example/train_base/stage.d.ts +3 -0
  45. package/dist/example/train_base/stage.js +246 -0
  46. package/dist/example/train_base/ui.d.ts +88 -0
  47. package/dist/example/train_base/ui.js +272 -0
  48. package/dist/src/meter.d.ts +1 -4
  49. package/dist/src/meter.js +0 -3
  50. package/dist/src/mind/attention.js +22 -20
  51. package/dist/src/mind/graph-search.d.ts +43 -9
  52. package/dist/src/mind/graph-search.js +82 -15
  53. package/dist/src/mind/junction.d.ts +13 -0
  54. package/dist/src/mind/junction.js +13 -0
  55. package/dist/src/mind/mechanisms/cover.js +23 -2
  56. package/dist/src/mind/mechanisms/prefix-completion.js +13 -11
  57. package/dist/src/mind/mechanisms/recall.js +8 -4
  58. package/dist/src/mind/mind.d.ts +1 -1
  59. package/dist/src/mind/mind.js +1 -1
  60. package/dist/src/mind/pipeline-mechanism.d.ts +0 -24
  61. package/dist/src/mind/pipeline-mechanism.js +13 -36
  62. package/dist/src/mind/pipeline.d.ts +23 -0
  63. package/dist/src/mind/pipeline.js +51 -3
  64. package/dist/src/mind/recognition.d.ts +6 -1
  65. package/dist/src/mind/recognition.js +11 -6
  66. package/dist/src/mind/resonance.js +48 -13
  67. package/dist/src/store.js +22 -1
  68. package/example/train_base/cache.ts +251 -0
  69. package/example/train_base/config.ts +128 -0
  70. package/example/train_base/corpora/aya.ts +106 -0
  71. package/example/train_base/corpora/converted-parquet.ts +64 -0
  72. package/example/train_base/corpora/genknow.ts +114 -0
  73. package/example/train_base/corpora/index.ts +88 -0
  74. package/example/train_base/corpora/massive.ts +111 -0
  75. package/example/train_base/corpora/oasst2.ts +163 -0
  76. package/example/train_base/corpora/smolsent.ts +203 -0
  77. package/example/train_base/corpora/soda.ts +130 -0
  78. package/example/train_base/corpora/taskmaster.ts +217 -0
  79. package/example/train_base/corpora/wiki2.ts +190 -0
  80. package/example/train_base/corpus.ts +150 -0
  81. package/example/train_base/discovery.ts +203 -0
  82. package/example/train_base/http.ts +284 -0
  83. package/example/train_base/items.ts +118 -0
  84. package/example/train_base/main.ts +240 -0
  85. package/example/train_base/progress.ts +149 -0
  86. package/example/train_base/readers.ts +505 -0
  87. package/example/train_base/runtime.ts +894 -0
  88. package/example/train_base/stage.ts +276 -0
  89. package/example/train_base/ui.ts +333 -0
  90. package/jsr.json +1 -1
  91. package/package.json +8 -5
  92. package/src/meter.ts +1 -4
  93. package/src/mind/attention.ts +22 -19
  94. package/src/mind/graph-search.ts +93 -16
  95. package/src/mind/junction.ts +13 -0
  96. package/src/mind/mechanisms/cover.ts +23 -4
  97. package/src/mind/mechanisms/prefix-completion.ts +13 -11
  98. package/src/mind/mechanisms/recall.ts +8 -4
  99. package/src/mind/mind.ts +1 -1
  100. package/src/mind/pipeline-mechanism.ts +13 -42
  101. package/src/mind/pipeline.ts +87 -3
  102. package/src/mind/recognition.ts +19 -6
  103. package/src/mind/resonance.ts +79 -50
  104. package/src/store.ts +21 -1
  105. package/test/13-conversation.test.mjs +1 -1
  106. package/test/84-composed-answer-honesty.test.mjs +2 -1
  107. package/test/88-dependency-footprint.test.mjs +99 -0
  108. package/test/89-completion-recursion.test.mjs +230 -0
  109. package/test/90-connector-read-cap.test.mjs +130 -0
  110. package/test/91-branch-bytes-cache.test.mjs +152 -0
  111. package/test/93-regime-prediction.test.mjs +148 -0
  112. package/test/94-cross-region-budget.test.mjs +67 -0
  113. package/test/95-wide-resonance-removed.test.mjs +109 -0
  114. package/dist/example/train_base.d.ts +0 -163
  115. package/dist/example/train_base.js +0 -3220
  116. package/example/train_base.ts +0 -3882
@@ -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
@@ -768,10 +812,12 @@ memoises perceive+intern of repeated inputs and routes through the same
768
812
  `store.commit()` at checkpoints; run `compactContentIndex` /
769
813
  `repairContentIndex` post-training if eviction was heavy, and
770
814
  `mind.buildCanonIndex()` if queries will carry a canonicalizer (2.9). See
771
- `example/train_base.ts`. Profiling note: the first `resonate` after a big ingest
772
- pays the pending index flush; the dominant query-side ANN cost is connector
773
- pre-resolution (bounded by recognised-site count don't add another loop over
774
- site pairs).
815
+ `example/train_base/` a folder, entry `main.ts`: the run context lives in
816
+ `runtime.ts`, the one per-corpus loop in `stage.ts`, and each corpus (knobs, row
817
+ adapter, stage descriptor) in `corpora/<name>.ts`. Profiling note: the first
818
+ `resonate` after a big ingest pays the pending index flush; the dominant
819
+ query-side ANN cost is connector pre-resolution (bounded by recognised-site
820
+ count — don't add another loop over site pairs).
775
821
 
776
822
  ---
777
823
 
@@ -811,6 +857,20 @@ vendor code under licenses incompatible with dual distribution, and do not add
811
857
  runtime dependencies casually — the near-zero-dependency footprint is a product
812
858
  feature.
813
859
 
860
+ **The library has NO runtime dependencies at all**, and that is now pinned by
861
+ `test/88-dependency-footprint.test.mjs`: the built `dist/src` may import only
862
+ `node:` builtins and relative paths, `package.json` may declare no
863
+ `dependencies`, and the published entry points may not reach outside `dist/src`.
864
+ The rule an EXAMPLE follows is different and looser — it may use what it needs,
865
+ as a **dev** dependency, loaded **lazily** so it is a requirement only of the
866
+ code path that uses it. `example/train_base` is the reference: `hyparquet` (+
867
+ its Snappy codec) is the sole third-party package in this repository, it is
868
+ dev-only, and `readers.ts` resolves it by dynamic import the first time a
869
+ Parquet corpus is actually read — so a curriculum with no Parquet stage runs
870
+ with the package absent. It used to sit in `dependencies`, which installed a
871
+ Parquet reader on every consumer of Sema for the sake of one example; that is
872
+ the mistake the suite exists to catch.
873
+
814
874
  **Training corpora are governed by the same rule, and more strictly.** Sema is
815
875
  non-parametric: a trained store retains its training text VERBATIM (read any
816
876
  content node back and the original sentence comes out). A store is therefore a
package/DATASETS.md CHANGED
@@ -77,17 +77,17 @@ changes to be stated. The modification statement above satisfies the latter.
77
77
  | [Taskmaster-1/2/3/4](https://github.com/google-research-datasets/Taskmaster) | CC BY 4.0 | Google LLC | Task-oriented dialogue. Only `utterances[].text` is ingested; the `instructions` / `scenario` / `vertical` fields are never read |
78
78
  | [2WikiMultihopQA](https://huggingface.co/datasets/xanhho/2WikiMultihopQA) — **`evidences` triples only** | Apache-2.0 (repo); triples originate in Wikidata, CC0 | Ho et al.; Wikidata contributors | Only the `evidences` column is ingested. The `context` column (Wikipedia prose, CC BY-SA) is **never read** — see §4. The `question`/`answer` columns are also never deposited, for a capability reason rather than a licence one: they memorise instead of composing |
79
79
  | [allenai/soda](https://huggingface.co/datasets/allenai/soda) | CC BY 4.0 | Allen Institute for AI | Social dialogue. Only the `dialogue` column is ingested; `narrative` / `literal` / `head` / `relation` / `tail` are never read. Model-generated provenance — see §5 |
80
- | [AmazonScience/massive](https://huggingface.co/datasets/AmazonScience/massive) | CC BY 4.0 | Amazon Science | Short multilingual intents. Only the `utt` column is ingested; the slot-annotated `annot_utt` is never read. **Disabled by default** on capability grounds (not licence) — see `MASSIVE` in `example/train_base.ts` |
80
+ | [AmazonScience/massive](https://huggingface.co/datasets/AmazonScience/massive) | CC BY 4.0 | Amazon Science | Short multilingual intents. Only the `utt` column is ingested; the slot-annotated `annot_utt` is never read. **Disabled by default** on capability grounds (not licence) — see `MASSIVE` in `example/train_base/corpora/massive.ts` |
81
81
 
82
82
  ### 3.2 Excluded, and why
83
83
 
84
- | Corpus | Reason |
85
- | :---------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
86
- | **MuskumPillerum/General-Knowledge** | **No licence at all.** The HF repo carries no licence tag and no licence in its card; an earlier header in `example/train_base.ts` claimed MIT without support. Its own card states it "contains a subset of the alpaca dataset", and Alpaca is CC BY-NC 4.0 — **NonCommercial**, incompatible with Sema's commercial licence. See §6. |
87
- | **PAWS** | Google's own grant is maximally permissive ("may be freely used for any purpose"), but PAWS-Wiki sentences derive from Wikipedia (CC BY-**SA**) and PAWS-QQP from Quora question pairs under Quora's terms. Because Sema retains text verbatim, the upstream terms would attach to the distributed store. Excluded despite strong measured fit. |
88
- | **Schema-Guided Dialogue (SGD/dstc8)**, **HotpotQA**, **MuSiQue** | CC BY-SA 4.0 — ShareAlike conflicts with dual distribution. |
89
- | **2WikiMultihopQA passages** | Wikipedia prose, CC BY-SA. The repo's Apache-2.0 tag does not relicense the text it was built from. Only the Wikidata-derived `evidences` triples are ingested. |
90
- | **Alpaca** and derivatives | CC BY-NC 4.0, and generated from OpenAI model outputs. |
84
+ | Corpus | Reason |
85
+ | :---------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
86
+ | **MuskumPillerum/General-Knowledge** | **No licence at all.** The HF repo carries no licence tag and no licence in its card; an earlier header in `example/train_base.ts` (now `corpora/genknow.ts`) claimed MIT without support. Its own card states it "contains a subset of the alpaca dataset", and Alpaca is CC BY-NC 4.0 — **NonCommercial**, incompatible with Sema's commercial licence. See §6. |
87
+ | **PAWS** | Google's own grant is maximally permissive ("may be freely used for any purpose"), but PAWS-Wiki sentences derive from Wikipedia (CC BY-**SA**) and PAWS-QQP from Quora question pairs under Quora's terms. Because Sema retains text verbatim, the upstream terms would attach to the distributed store. Excluded despite strong measured fit. |
88
+ | **Schema-Guided Dialogue (SGD/dstc8)**, **HotpotQA**, **MuSiQue** | CC BY-SA 4.0 — ShareAlike conflicts with dual distribution. |
89
+ | **2WikiMultihopQA passages** | Wikipedia prose, CC BY-SA. The repo's Apache-2.0 tag does not relicense the text it was built from. Only the Wikidata-derived `evidences` triples are ingested. |
90
+ | **Alpaca** and derivatives | CC BY-NC 4.0, and generated from OpenAI model outputs. |
91
91
 
92
92
  ---
93
93
 
@@ -154,6 +154,7 @@ Stores published before this file was written — including those under
154
154
  rows), whose licence status is described in §3.2. Those artifacts should be
155
155
  treated as **not redistributable** until retrained without that stage.
156
156
 
157
- The stage is now **disabled by default** in `example/train_base.ts`
158
- (`GENKNOW=0`). The adapter code remains so the stage can be re-enabled for local
159
- experiments; a store trained with `GENKNOW=1` must not be distributed.
157
+ The stage is now **disabled by default** in
158
+ `example/train_base/corpora/genknow.ts` (`GENKNOW=0`). The adapter code remains
159
+ so the stage can be re-enabled for local experiments; a store trained with
160
+ `GENKNOW=1` must not be distributed.
@@ -0,0 +1,35 @@
1
+ /** Delete every orphaned "<file>.part" in the cache, returning how many were
2
+ * removed and the bytes they held.
3
+ *
4
+ * A .part file at rest is by definition the debris of a download that never
5
+ * finished — the rename that promotes one is the last step of `downloadFile`,
6
+ * so a live .part exists only while THIS process is writing it. Sweeping at
7
+ * startup is therefore safe, and it is load-bearing rather than cosmetic:
8
+ * `cacheSize` deliberately counts .part files (an in-flight download really
9
+ * does occupy the disk), so debris left by a killed run consumes ceiling
10
+ * budget that nothing would ever free, and `ensureCacheRoom` would wait for
11
+ * room that cannot appear.
12
+ *
13
+ * The one assumption is that a cache directory belongs to ONE run at a time.
14
+ * That was already true — two trainers sharing CACHE_DIR would write the same
15
+ * .part path — so this adds no constraint that did not exist. */
16
+ export declare function sweepPartials(): {
17
+ files: number;
18
+ bytes: number;
19
+ };
20
+ /** Total bytes currently held in the cache directory — INCLUDING any .part
21
+ * file, because an in-flight download occupies the disk like any other file.
22
+ * Orphaned ones are removed by {@link sweepPartials} at startup. */
23
+ export declare function cacheSize(): number;
24
+ /** Block until there is room for a file of `fileBytes` under the ceiling.
25
+ * A single file larger than the whole ceiling can never "fit", so we let it
26
+ * through (it is deleted right after processing) rather than wait forever. */
27
+ export declare function ensureCacheRoom(fileBytes: number, signal: AbortSignal, warn?: (msg: string) => void, maxWaitMs?: number): Promise<void>;
28
+ export interface DownloadOptions {
29
+ signal: AbortSignal;
30
+ tries: number;
31
+ onFail?: (attempt: number, err: Error) => void;
32
+ onProgress?: (done: number, total: number) => void;
33
+ }
34
+ /** Stream `url` to `destPath`, atomically and with backpressure. */
35
+ export declare function downloadFile(url: string, destPath: string, opts: DownloadOptions): Promise<void>;
@@ -0,0 +1,211 @@
1
+ // train_base/cache.ts — the durable disk cache and the download sink.
2
+ //
3
+ // This is the ONE place the trainer needs Node rather than the web platform:
4
+ // every other byte in the pipeline moves through fetch, WHATWG streams,
5
+ // DecompressionStream, TextDecoderStream and Blob, but writing a file is the
6
+ // single capability the web platform does not expose. So the sink below wraps a
7
+ // raw fs descriptor, and nothing else here does.
8
+ //
9
+ // Two invariants the rest of the trainer relies on:
10
+ // • ATOMIC — a download streams to "<file>.part", is fsync'd, then renamed
11
+ // into place. A file at its final path is, by construction, complete, so an
12
+ // interrupted download can never be mistaken for a cached one.
13
+ // • BOUNDED — a download blocks under the MAX_CACHE_GB ceiling, and a fully
14
+ // processed file is deleted by its caller immediately.
15
+ import { CACHE_DIR, CACHE_WAIT_MS, MAX_CACHE_BYTES, PART_SUFFIX, } from "./config.js";
16
+ import { httpError, retry, waitMs } from "./http.js";
17
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, renameSync, statSync, unlinkSync, writeSync, } from "node:fs";
18
+ import { basename, join } from "node:path";
19
+ /** Delete every orphaned "<file>.part" in the cache, returning how many were
20
+ * removed and the bytes they held.
21
+ *
22
+ * A .part file at rest is by definition the debris of a download that never
23
+ * finished — the rename that promotes one is the last step of `downloadFile`,
24
+ * so a live .part exists only while THIS process is writing it. Sweeping at
25
+ * startup is therefore safe, and it is load-bearing rather than cosmetic:
26
+ * `cacheSize` deliberately counts .part files (an in-flight download really
27
+ * does occupy the disk), so debris left by a killed run consumes ceiling
28
+ * budget that nothing would ever free, and `ensureCacheRoom` would wait for
29
+ * room that cannot appear.
30
+ *
31
+ * The one assumption is that a cache directory belongs to ONE run at a time.
32
+ * That was already true — two trainers sharing CACHE_DIR would write the same
33
+ * .part path — so this adds no constraint that did not exist. */
34
+ export function sweepPartials() {
35
+ const out = { files: 0, bytes: 0 };
36
+ if (!existsSync(CACHE_DIR))
37
+ return out;
38
+ for (const name of readdirSync(CACHE_DIR)) {
39
+ if (!name.endsWith(PART_SUFFIX))
40
+ continue;
41
+ const p = join(CACHE_DIR, name);
42
+ try {
43
+ const size = statSync(p).size;
44
+ unlinkSync(p);
45
+ out.files++;
46
+ out.bytes += size;
47
+ }
48
+ catch { /* raced with another delete — nothing to reclaim */ }
49
+ }
50
+ return out;
51
+ }
52
+ /** Total bytes currently held in the cache directory — INCLUDING any .part
53
+ * file, because an in-flight download occupies the disk like any other file.
54
+ * Orphaned ones are removed by {@link sweepPartials} at startup. */
55
+ export function cacheSize() {
56
+ if (!existsSync(CACHE_DIR))
57
+ return 0;
58
+ let total = 0;
59
+ for (const name of readdirSync(CACHE_DIR)) {
60
+ try {
61
+ total += statSync(join(CACHE_DIR, name)).size;
62
+ }
63
+ catch { /* raced with a delete */ }
64
+ }
65
+ return total;
66
+ }
67
+ /** Block until there is room for a file of `fileBytes` under the ceiling.
68
+ * A single file larger than the whole ceiling can never "fit", so we let it
69
+ * through (it is deleted right after processing) rather than wait forever. */
70
+ export async function ensureCacheRoom(fileBytes, signal, warn, maxWaitMs = CACHE_WAIT_MS) {
71
+ mkdirSync(CACHE_DIR, { recursive: true });
72
+ if (fileBytes >= MAX_CACHE_BYTES)
73
+ return;
74
+ let warned = false;
75
+ const until = Date.now() + maxWaitMs;
76
+ // Stop waiting the moment a shutdown is requested — the abort signal unblocks
77
+ // a long cache-full wait so Ctrl+C is never swallowed by the ceiling.
78
+ while (!signal.aborted && cacheSize() + fileBytes > MAX_CACHE_BYTES) {
79
+ // BOUNDED. Room appears when this run consumes and deletes a file, so a
80
+ // cache already over the ceiling with nothing left to consume — stale files
81
+ // from another run, a ceiling set below one corpus — would otherwise wait
82
+ // for room that cannot arrive, forever, after a single warning line.
83
+ if (Date.now() >= until) {
84
+ throw new Error(`cache still full after ${Math.round(maxWaitMs / 60_000)} min ` +
85
+ `(${(cacheSize() / 1e9).toFixed(1)} GB of a ` +
86
+ `${(MAX_CACHE_BYTES / 1e9).toFixed(0)} GB ceiling) — raise ` +
87
+ `MAX_CACHE_GB or clear ${CACHE_DIR}`);
88
+ }
89
+ if (!warned) {
90
+ warn?.(`cache at ${(MAX_CACHE_BYTES / 1e9).toFixed(0)} GB ceiling — waiting for room…`);
91
+ warned = true;
92
+ }
93
+ await waitMs(5_000, signal);
94
+ }
95
+ }
96
+ /** Stream `url` to `destPath`, atomically and with backpressure. */
97
+ export async function downloadFile(url, destPath, opts) {
98
+ const partPath = destPath + PART_SUFFIX;
99
+ await retry(`download ${basename(destPath)}`, async () => {
100
+ // Abort promptly on shutdown rather than waiting out a slow socket.
101
+ if (opts.signal.aborted) {
102
+ const e = new Error("aborted");
103
+ e.fatal = true;
104
+ throw e;
105
+ }
106
+ const res = await fetch(url, { signal: opts.signal });
107
+ if (!res.ok)
108
+ throw httpError(res);
109
+ if (!res.body)
110
+ throw new Error("empty response body");
111
+ // `content-length` describes the bytes ON THE WIRE. When the server
112
+ // applied a content-coding, fetch hands us the DECODED body, so the
113
+ // header no longer describes what gets written to disk and the integrity
114
+ // guard below must not use it. Measured: raw.githubusercontent.com sends
115
+ // `content-encoding: gzip` with content-length 110,928 for a file that
116
+ // decodes to 1,607,931 bytes — a size check against that rejects every
117
+ // healthy download. (The bug stayed latent because Hugging Face sends
118
+ // `content-encoding: br` and NO content-length, leaving total = 0, which
119
+ // already disables the guard.)
120
+ const encoding = (res.headers.get("content-encoding") ?? "").trim()
121
+ .toLowerCase();
122
+ const decoded = encoding !== "" && encoding !== "identity";
123
+ const total = decoded
124
+ ? 0
125
+ : Number(res.headers.get("content-length")) || 0;
126
+ let done = 0;
127
+ // Stream straight to a ".part" sibling using pure WHATWG streams. A
128
+ // TransformStream meters progress; pipeTo into a WritableStream gives REAL
129
+ // backpressure natively — the sink's write() returns a promise the
130
+ // readable side awaits, so a fast server can never outrun the disk (no
131
+ // whole-file heap buffering). The sink wraps a single raw fs descriptor
132
+ // (the one capability the web platform lacks); writing to disk is the only
133
+ // Node operation in the whole pipeline. The final, valid file only ever
134
+ // appears via the atomic rename below, so a crash mid-transfer can never
135
+ // leave a truncated file at the real path.
136
+ const meter = new TransformStream({
137
+ transform(chunk, controller) {
138
+ done += chunk.length;
139
+ opts.onProgress?.(done, total);
140
+ controller.enqueue(chunk);
141
+ },
142
+ });
143
+ const fd = openSync(partPath, "w");
144
+ let closed = false;
145
+ const closeFd = () => {
146
+ if (closed)
147
+ return;
148
+ closed = true;
149
+ try {
150
+ closeSync(fd);
151
+ }
152
+ catch { /* already closed */ }
153
+ };
154
+ const sink = new WritableStream({
155
+ write(chunk) {
156
+ // writeSync drains the whole chunk before returning, so the readable
157
+ // side is paused for exactly as long as the disk needs — backpressure.
158
+ let off = 0;
159
+ while (off < chunk.length) {
160
+ off += writeSync(fd, chunk, off, chunk.length - off);
161
+ }
162
+ },
163
+ close() {
164
+ fsyncSync(fd); // durable bytes before the rename promotes them
165
+ closeFd();
166
+ },
167
+ abort() {
168
+ closeFd();
169
+ },
170
+ });
171
+ try {
172
+ await res.body.pipeThrough(meter).pipeTo(sink, {
173
+ signal: opts.signal,
174
+ });
175
+ }
176
+ catch (e) {
177
+ // pipeTo's abort() ran the sink's abort() (closing the descriptor); if
178
+ // it didn't (a non-abort throw), make sure the descriptor is not leaked.
179
+ closeFd();
180
+ try {
181
+ unlinkSync(partPath);
182
+ }
183
+ catch { /* best effort */ }
184
+ throw e;
185
+ }
186
+ // Optional integrity guard: when the server advertised a size FOR THE
187
+ // BYTES WE WRITE (see the content-encoding note above — `total` is 0 for
188
+ // a decoded body, which disables this), a complete file must match it. A
189
+ // short read (silent truncation) is retried rather than promoted, so the
190
+ // parser never sees a partial file.
191
+ try {
192
+ const got = statSync(partPath).size;
193
+ if (total > 0 && got !== total) {
194
+ try {
195
+ unlinkSync(partPath);
196
+ }
197
+ catch { /* best effort */ }
198
+ throw new Error(`size mismatch: got ${got}, expected ${total}`);
199
+ }
200
+ }
201
+ catch (e) {
202
+ if (e instanceof Error && e.message.startsWith("size mismatch")) {
203
+ throw e;
204
+ }
205
+ // statSync failure is non-fatal here; the rename below will surface it.
206
+ }
207
+ // Atomic publish: rename is atomic within a filesystem, so the final path
208
+ // flips from "absent" to "complete" in one step — never an in-between.
209
+ renameSync(partPath, destPath);
210
+ }, opts.tries, { signal: opts.signal, onFail: opts.onFail });
211
+ }
@@ -0,0 +1,21 @@
1
+ /** Read an environment variable, or `d` when it is unset. */
2
+ export declare const env: (k: string, d: string) => string;
3
+ export declare const DB_PATH: string;
4
+ export declare const D: number;
5
+ export declare const SEED: number;
6
+ export declare const CHECKPOINT_BYTES: number;
7
+ export declare const PARQUET_BATCH_BYTES: number;
8
+ export declare const LOCAL_PATH: string;
9
+ export declare const CACHE_DIR: string;
10
+ export declare const MAX_CACHE_BYTES: number;
11
+ export declare const PROGRESS_MS: number;
12
+ export declare const INDEX_MAINTENANCE: boolean;
13
+ export declare const DOWNLOAD_TRIES = 5;
14
+ export declare const PART_SUFFIX = ".part";
15
+ export declare const INFER_TIMEOUT_MS: number;
16
+ export declare const STALL_MS: number;
17
+ export declare const CACHE_WAIT_MS: number;
18
+ export declare const VECTOR_CACHE_MB: number;
19
+ export declare const SQLITE_CACHE_MB: number;
20
+ export declare const MAX_MB: number;
21
+ export declare const MAX_BYTES: number;
@@ -0,0 +1,94 @@
1
+ // train_base/config.ts — RUN-LEVEL configuration, all from the environment.
2
+ //
3
+ // Only knobs that describe the RUN live here: the store, the checkpoint cadence,
4
+ // the cache ceiling, the read budgets, the caps. A knob that describes ONE
5
+ // CORPUS (which pairs of SmolSent, how many SODA dialogues, how long an Aya
6
+ // field may be) belongs next to that corpus's adapter, together with the
7
+ // evidence that fixed its default — see AGENTS.md §2.16: a comment carries the
8
+ // constraint, and a constraint is only readable beside the code it constrains.
9
+ import { join } from "node:path";
10
+ /** Read an environment variable, or `d` when it is unset. */
11
+ export const env = (k, d) => process.env[k] ?? d;
12
+ export const DB_PATH = env("DB_PATH", "sema"); // → {DB_PATH}.sqlite
13
+ export const D = Number(env("D", "1024"));
14
+ export const SEED = Number(env("SEED", "7"));
15
+ // Checkpoint cadence is measured in LEARNED CONTENT, not deposits: a snapshot
16
+ // every CHECKPOINT_MB megabytes of trained UTF-8 content (decimal MB, matching
17
+ // the bytes() helper). A floor of 1 MB: a zero/NaN value must not make every
18
+ // deposit checkpoint, nor silently disable checkpointing. The tail (a run that
19
+ // learns less than one interval, or the remainder past the last interval) is
20
+ // always saved by finish() at exit — a complete point.
21
+ export const CHECKPOINT_BYTES = Math.max(1_000_000, Math.floor(Number(env("CHECKPOINT_MB", "100")) * 1_000_000) || 100_000_000);
22
+ // Target size of ONE materialised Parquet read, in uncompressed source bytes.
23
+ // A row-GROUP is a layout choice made by whoever wrote the file, not a memory
24
+ // budget: Aya ships 203 groups of 1,000 rows (~1 MB each), while SODA ships ONE
25
+ // group of 1,191,582 rows (1.19 GB uncompressed) and 2Wiki ONE of 167,454
26
+ // (666 MB). Reading "exactly one row-group" is therefore safe for the first and
27
+ // fatal for the others, so reads are sized in BYTES instead — see
28
+ // `parquetBatchRows`. Materialised JS objects cost several times their source
29
+ // bytes, hence a default well under available memory.
30
+ export const PARQUET_BATCH_BYTES = Math.max(1_000_000, Math.floor(Number(env("PARQUET_BATCH_MB", "32")) * 1_000_000) || 32_000_000);
31
+ export const LOCAL_PATH = env("LOCAL_PATH", ""); // train from a local dir
32
+ export const CACHE_DIR = env("CACHE_DIR", join(process.cwd(), "cache"));
33
+ export const MAX_CACHE_BYTES = Number(env("MAX_CACHE_GB", "100")) * 1e9;
34
+ export const PROGRESS_MS = Number(env("PROGRESS_MS", "250")); // panel cadence
35
+ // Index maintenance at checkpoints: compact (remove garbage), repair (fill
36
+ // gaps), then refresh the canonical-form index (equivalence-class resolution —
37
+ // src/canon.ts). All three are idempotent batch operations (the canon build is
38
+ // additionally incremental via the store's `canon.upto` cursor);
39
+ // INDEX_MAINTENANCE=0 disables.
40
+ export const INDEX_MAINTENANCE = env("INDEX_MAINTENANCE", "1") !== "0";
41
+ export const DOWNLOAD_TRIES = 5;
42
+ // In-progress downloads are written to a sibling "<dest>.part" and atomically
43
+ // renamed into place only after the bytes are fully flushed to disk. The cache
44
+ // invariant is therefore absolute: a file at its final path is, by definition,
45
+ // complete. Partial transfers (a crash, a kill, a dropped socket) leave only a
46
+ // .part file, which is never fed to the parser and is swept at startup by
47
+ // cache.ts's sweepPartials() — without which the debris would consume cache
48
+ // ceiling that nothing frees.
49
+ export const PART_SUFFIX = ".part";
50
+ // The checkpoint recall is a best-effort diagnostic — it must NEVER stall
51
+ // training. We bound it so a slow/large store cannot freeze the deposit loop.
52
+ export const INFER_TIMEOUT_MS = Number(env("INFER_TIMEOUT_MS", "15000"));
53
+ // How long the run may make NO progress before it gives up and exits non-zero.
54
+ //
55
+ // A long training run's worst failure is not a crash — a crash resumes. It is a
56
+ // HANG: the uncaught-exception handler deliberately swallows dropped-connection
57
+ // errors so a long run survives them, and the keep-alive timer deliberately
58
+ // holds the process open; together, an error that escapes and leaves an await
59
+ // unsettled produces a live process that will never do anything again. No error,
60
+ // no exit, and a supervisor that sees a healthy pid. Exiting instead turns that
61
+ // into a resume, which costs at most the work since the last checkpoint.
62
+ //
63
+ // "Progress" is any deposit, downloaded chunk, or rate-limit wait; time inside
64
+ // index maintenance and the checkpoint recall does not count against it, since
65
+ // those legitimately deposit nothing. Generous by default — this is a
66
+ // last-resort backstop, not a latency budget. 0 disables it.
67
+ export const STALL_MS = Math.max(0, Math.floor(Number(env("STALL_MIN", "15")) * 60_000) || 900_000);
68
+ // How long a download may wait for room under the cache ceiling before failing
69
+ // the unit instead of waiting forever. The wait exists so a bounded cache can
70
+ // throttle a fast source; it is not meant to outlast the run. The unit stays
71
+ // resumable, so a genuine ceiling problem costs a retry, not the corpus.
72
+ export const CACHE_WAIT_MS = Math.max(60_000, Math.floor(Number(env("CACHE_WAIT_MIN", "10")) * 60_000) || 600_000);
73
+ // The vector indices' memory knob (MiB) — each index's SQLite page cache.
74
+ // The IVF index routes inserts through a RAM-resident pivot table and
75
+ // appends to chunk blobs, so this cache mostly serves query-time cluster
76
+ // scans; 256 MiB comfortably covers the probed working set of a trained
77
+ // store. Override with VECTOR_CACHE_MB (64 is the library default).
78
+ export const VECTOR_CACHE_MB = Math.max(0, Number(env("VECTOR_CACHE_MB", "256")));
79
+ // Page cache for the MAIN DAG database (node/kid/edge/contain tables).
80
+ // Training issues millions of content-addressed point probes per session
81
+ // against a GB-scale file; the library default (64 MiB) is sized for a
82
+ // small machine — a training box affords more. Override with
83
+ // SQLITE_CACHE_MB.
84
+ export const SQLITE_CACHE_MB = Math.max(0, Number(env("SQLITE_CACHE_MB", "256")));
85
+ // Optional ceiling on how much LEARNED CONTENT to train, in megabytes (decimal,
86
+ // like CHECKPOINT_MB). Default Infinity = unbounded. The cap is checked against
87
+ // trainedContentBytes after each deposit, so a run stops at the first item that
88
+ // carries the running total to/past the ceiling (that item is still counted).
89
+ export const MAX_MB = Number(env("MAX_MB", "Infinity"));
90
+ if (isNaN(MAX_MB) || MAX_MB < 0) {
91
+ process.stderr.write(`fatal: MAX_MB must be a non-negative number or "Infinity"\n`);
92
+ process.exit(1);
93
+ }
94
+ export const MAX_BYTES = MAX_MB * 1_000_000; // Infinity stays Infinity
@@ -0,0 +1,19 @@
1
+ import { type TrainingItem } from "../items.js";
2
+ import { type Corpus } from "../corpus.js";
3
+ /** One normalized Aya row. */
4
+ export interface AyaRow {
5
+ inputs: string;
6
+ targets: string;
7
+ language: string;
8
+ }
9
+ /** Normalize a raw datasets-server row object into an AyaRow, or null when it
10
+ * lacks a usable prompt/answer or a field is implausibly large (a dump, not a
11
+ * cognitive example). Trims surrounding whitespace; keeps inner text verbatim
12
+ * (human prose, possibly multi-paragraph). */
13
+ export declare function toAyaRow(row: unknown, maxChars?: number): AyaRow | null;
14
+ /** Translate ONE Aya row into SEMA training items. A row is a single human
15
+ * (question → answer) exchange — exactly one FACT, the (inputs → targets) edge.
16
+ * No standalone-answer experience and no one-exchange "cumulative" walk: a lone
17
+ * Q→A is not multi-turn, and both would only replicate the same edge. */
18
+ export declare function ayaRowToItems(row: AyaRow): TrainingItem[];
19
+ export declare const aya: Corpus;