@hviana/sema 0.7.8 → 0.8.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.
@@ -16,7 +16,7 @@
16
16
  // So this engine stays decoupled: it knows the graph's shape, never how the
17
17
  // graph was learnt.
18
18
  import { lightestDerivation, } from "../derive/src/index.js";
19
- import { bytesEqual, concat2, concatBytes, latin1 } from "../bytes.js";
19
+ import { bytesEqual, concat2, concatBytes, indexOf, latin1 } from "../bytes.js";
20
20
  import { ALL } from "./types.js";
21
21
  // The cost ladder is a strict ORDERING, not tuned magic:
22
22
  // • Coverage dominates everything: leaving one query byte unrecognised (PASS)
@@ -894,14 +894,34 @@ export class GraphSearch {
894
894
  // The entity candidates are the forms the fact's own bytes CONTAIN — the
895
895
  // same recogniser the query went through, so the evidence standard is the
896
896
  // query's. A byte atom is never a subject; the fact's own node is the span
897
- // itself, not an entity inside it.
898
- for (const site of this.host.recogniseSpan(fact.bytes).sites) {
899
- if (site.payload < 0 || site.payload === fact.node)
900
- continue;
901
- if (!this.store.hasNext(site.payload) &&
902
- !this.store.hasHalo(site.payload))
903
- continue;
904
- const key = this.host.resolve(concat2(this.store.bytesPrefix(site.payload, ALL), tail));
897
+ // itself, not an entity inside it. The admission predicate has ONE
898
+ // definition — `traverse.ts`'s `leadsSomewhere` (edge or halo); the host
899
+ // LENDS it when it can (Mind does, with the response-scoped struct cache),
900
+ // and a bare host falls back to the raw-store probe, so the search stays
901
+ // host-based.
902
+ const leading = this.host.recogniseSpan(fact.bytes).sites.filter((s) => s.payload >= 0 && s.payload !== fact.node &&
903
+ (this.host.leadsSomewhere !== undefined
904
+ ? this.host.leadsSomewhere(s.payload)
905
+ : this.store.hasNext(s.payload) || this.store.hasHalo(s.payload)));
906
+ // …then prefer the entity the query did NOT name, and the MAXIMAL one. The
907
+ // join exists to reach the subject the query never wrote, so:
908
+ // • a candidate the query already contains is the query's OWN subject, and
909
+ // following its fact answers about that subject instead of the inferred
910
+ // one (measured: "Gustaf Molander spouse date of death" gave Gustaf's
911
+ // date of death, not his spouse's);
912
+ // • a candidate contained in a longer one is not the entity the fact
913
+ // introduces ("Timur" must not win over "Timur Bekmambetov").
914
+ // Byte work over bytes already read, and the pruning REMOVES the
915
+ // resolve()/nextFirst() probes these candidates would have paid.
916
+ const candidates = leading
917
+ .map((s) => ({
918
+ payload: s.payload,
919
+ bytes: this.store.bytesPrefix(s.payload, ALL),
920
+ }))
921
+ .filter((c) => indexOf(queryBytes, c.bytes, 0) < 0)
922
+ .filter((c, _i, all) => !all.some((o) => o.bytes.length > c.bytes.length && indexOf(o.bytes, c.bytes, 0) >= 0));
923
+ for (const c of candidates) {
924
+ const key = this.host.resolve(concat2(c.bytes, tail));
905
925
  if (key === null)
906
926
  continue;
907
927
  const nx = this.store.nextFirst(key, 1);
@@ -150,6 +150,11 @@ export declare class Mind implements MindContext {
150
150
  private _conversations;
151
151
  /** Canonical node id of a byte span. Required by GraphSearchHost & MindContext. */
152
152
  resolve(bytes: Uint8Array): number | null;
153
+ /** Whether a node leads somewhere — the admission predicate, delegating to
154
+ * `traverse.ts`'s ONE definition (edge or halo, with its response-scoped
155
+ * cache). The search holds a bare Store and cannot reach that cache itself,
156
+ * so it asks through this hook; a bare host keeps its raw-store fallback. */
157
+ leadsSomewhere(id: number): boolean;
153
158
  recogniseSpan(bytes: Uint8Array): {
154
159
  sites: ReadonlyArray<Site>;
155
160
  leaves: ReadonlyArray<Leaf>;
@@ -20,7 +20,7 @@ import { GraphSearch, } from "./graph-search.js";
20
20
  import { Alu } from "../alu/src/index.js";
21
21
  import { decodeText, Rationale, } from "./rationale.js";
22
22
  import { gistOf, inputBytes, perceive as perceiveImpl, perceiveKey, resolve as resolveImpl, } from "./primitives.js";
23
- import { chooseNext, edgeAncestors as edgeAncestorsFn, invalidateStructuralCaches, } from "./traverse.js";
23
+ import { chooseNext, edgeAncestors as edgeAncestorsFn, invalidateStructuralCaches, leadsSomewhere, } from "./traverse.js";
24
24
  import { invalidateJunctionCache } from "./junction.js";
25
25
  import { follow } from "./match.js";
26
26
  import { recognise, segment } from "./recognition.js";
@@ -112,6 +112,13 @@ export class Mind {
112
112
  resolve(bytes) {
113
113
  return resolveImpl(this, bytes);
114
114
  }
115
+ /** Whether a node leads somewhere — the admission predicate, delegating to
116
+ * `traverse.ts`'s ONE definition (edge or halo, with its response-scoped
117
+ * cache). The search holds a bare Store and cannot reach that cache itself,
118
+ * so it asks through this hook; a bare host keeps its raw-store fallback. */
119
+ leadsSomewhere(id) {
120
+ return leadsSomewhere(this, id);
121
+ }
115
122
  // recogniseSpan wraps recognise
116
123
  recogniseSpan(bytes) {
117
124
  const r = recognise(this, bytes);
@@ -514,18 +514,21 @@ function recogniseImpl(ctx, bytes) {
514
514
  // starts on a fold cut nor ends on a node edge was unreachable by either
515
515
  // tier — the exact site whose loss `tryChain`'s own note records as "the
516
516
  // pivot dies with the site and multi-hop goes silent". The interior
517
- // pass below spends the same budget on those pairs.
517
+ // pass below spends the same budget on those pairs. Returns whether it
518
+ // emitted, so a caller can retry a trimmed edge on the miss path only.
518
519
  if (end - start < W)
519
- return;
520
+ return false;
520
521
  if (flatProbe(start, end) === null) {
521
522
  if (!canonBudget)
522
- return;
523
+ return false;
523
524
  if (!canonAdmits(start, end))
524
- return;
525
+ return false;
525
526
  }
526
527
  const id = resolveSpan(start, end);
527
- if (id !== null)
528
- emit(start, end, id);
528
+ if (id === null)
529
+ return false;
530
+ emit(start, end, id);
531
+ return true;
529
532
  };
530
533
  // A CUMULATIVE BYTE BUDGET, SPENT SHORTEST-SPAN-FIRST.
531
534
  //
@@ -562,10 +565,7 @@ function recogniseImpl(ctx, bytes) {
562
565
  const afford = span <= budget;
563
566
  if (afford)
564
567
  budget -= span;
565
- probe(start, end, afford);
566
- // Always keep walking: the exact route is unbudgeted, so running out
567
- // of canon budget must not stop the scan.
568
- return true;
568
+ return probe(start, end, afford);
569
569
  };
570
570
  const prefixes = ordered.filter((e) => e > 0).sort((a, b) => a - b);
571
571
  const suffixes = ordered
@@ -573,11 +573,26 @@ function recogniseImpl(ctx, bytes) {
573
573
  .sort((a, b) => b - a);
574
574
  for (let i = 0; i < Math.max(prefixes.length, suffixes.length); i++) {
575
575
  // Interleaved so neither edge starves the other when the budget runs
576
- // out — a query can carry a trained form at either end.
577
- if (i < prefixes.length && !spend(0, prefixes[i]))
578
- break;
579
- if (i < suffixes.length && !spend(suffixes[i], bytes.length))
580
- break;
576
+ // out — a query can carry a trained form at either end. The scan never
577
+ // stops on a miss: the exact route is unbudgeted, so running out of
578
+ // canon budget must not stop it.
579
+ if (i < prefixes.length)
580
+ spend(0, prefixes[i]);
581
+ if (i < suffixes.length) {
582
+ const s = suffixes[i];
583
+ // An edge form can end ONE byte before the edge does: a query often
584
+ // closes with a separator ("…is Timur Bekmambetov.") that belongs
585
+ // BETWEEN forms, and the text canonicalizer passes punctuation through,
586
+ // so the full-edge probe can never match it. Retry the trimmed edge
587
+ // ON THE MISS PATH ONLY — self-verifying (resolve decides, so a wrong
588
+ // trim can never emit), the same ±1 edge-trim discipline the canon-miss
589
+ // fallback above already trusts, and the hit path pays nothing. A form
590
+ // LONGER than `chainReach` at the sentence end (measured: "Timur
591
+ // Bekmambetov", 17 bytes) is recovered only here — the interior pass is
592
+ // capped at `chainReach`.
593
+ if (!spend(s, bytes.length))
594
+ spend(s, bytes.length - 1);
595
+ }
581
596
  }
582
597
  // INTERIOR pairs within the same `chainReach(W)` span bound the chain
583
598
  // trusts — the dead zone the gate above used to leave: a form that neither
@@ -35,6 +35,12 @@ export interface GraphSearchHost {
35
35
  starts: ReadonlySet<number>;
36
36
  };
37
37
  chooseNext?(node: number): number | undefined;
38
+ /** The admission predicate — `traverse.ts`'s `leadsSomewhere`, its ONE
39
+ * definition: does this node bear an edge or a halo? Optional, so a bare
40
+ * host (a raw Store and nothing else) still works; when present, the search
41
+ * uses it rather than re-probing the store, which keeps the predicate
42
+ * single-defined AND memoised on the response-scoped struct cache. */
43
+ leadsSomewhere?(id: number): boolean;
38
44
  }
39
45
  export interface Recognition {
40
46
  /** Forms that can lead somewhere — they have an edge or a halo. */
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://jsr.io/schema/config-file.v1.json",
3
3
  "name": "@hviana/sema",
4
- "version": "0.7.8",
4
+ "version": "0.8.0",
5
5
  "exports": "./src/index.ts"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hviana/sema",
3
- "version": "0.7.8",
3
+ "version": "0.8.0",
4
4
  "description": "Sema: a non-parametric, instance-based reasoning system.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -23,7 +23,7 @@ import {
23
23
  type Rule,
24
24
  } from "../derive/src/index.js";
25
25
  import type { Store } from "../store.js";
26
- import { bytesEqual, concat2, concatBytes, latin1 } from "../bytes.js";
26
+ import { bytesEqual, concat2, concatBytes, indexOf, latin1 } from "../bytes.js";
27
27
  import type { GraphSearchHost } from "./types.js";
28
28
 
29
29
  import { ALL } from "./types.js";
@@ -1165,16 +1165,40 @@ export class GraphSearch {
1165
1165
  // The entity candidates are the forms the fact's own bytes CONTAIN — the
1166
1166
  // same recogniser the query went through, so the evidence standard is the
1167
1167
  // query's. A byte atom is never a subject; the fact's own node is the span
1168
- // itself, not an entity inside it.
1169
- for (const site of this.host.recogniseSpan(fact.bytes).sites) {
1170
- if (site.payload < 0 || site.payload === fact.node) continue;
1171
- if (
1172
- !this.store.hasNext(site.payload) &&
1173
- !this.store.hasHalo(site.payload)
1174
- ) continue;
1175
- const key = this.host.resolve(
1176
- concat2(this.store.bytesPrefix(site.payload, ALL), tail),
1168
+ // itself, not an entity inside it. The admission predicate has ONE
1169
+ // definition — `traverse.ts`'s `leadsSomewhere` (edge or halo); the host
1170
+ // LENDS it when it can (Mind does, with the response-scoped struct cache),
1171
+ // and a bare host falls back to the raw-store probe, so the search stays
1172
+ // host-based.
1173
+ const leading = this.host.recogniseSpan(fact.bytes).sites.filter((s) =>
1174
+ s.payload >= 0 && s.payload !== fact.node &&
1175
+ (this.host.leadsSomewhere !== undefined
1176
+ ? this.host.leadsSomewhere(s.payload)
1177
+ : this.store.hasNext(s.payload) || this.store.hasHalo(s.payload))
1178
+ );
1179
+ // …then prefer the entity the query did NOT name, and the MAXIMAL one. The
1180
+ // join exists to reach the subject the query never wrote, so:
1181
+ // • a candidate the query already contains is the query's OWN subject, and
1182
+ // following its fact answers about that subject instead of the inferred
1183
+ // one (measured: "Gustaf Molander spouse date of death" gave Gustaf's
1184
+ // date of death, not his spouse's);
1185
+ // • a candidate contained in a longer one is not the entity the fact
1186
+ // introduces ("Timur" must not win over "Timur Bekmambetov").
1187
+ // Byte work over bytes already read, and the pruning REMOVES the
1188
+ // resolve()/nextFirst() probes these candidates would have paid.
1189
+ const candidates = leading
1190
+ .map((s) => ({
1191
+ payload: s.payload,
1192
+ bytes: this.store.bytesPrefix(s.payload, ALL),
1193
+ }))
1194
+ .filter((c) => indexOf(queryBytes, c.bytes, 0) < 0)
1195
+ .filter((c, _i, all) =>
1196
+ !all.some((o) =>
1197
+ o.bytes.length > c.bytes.length && indexOf(o.bytes, c.bytes, 0) >= 0
1198
+ )
1177
1199
  );
1200
+ for (const c of candidates) {
1201
+ const key = this.host.resolve(concat2(c.bytes, tail));
1178
1202
  if (key === null) continue;
1179
1203
  const nx = this.store.nextFirst(key, 1);
1180
1204
  if (nx.length === 0) continue;
package/src/mind/mind.ts CHANGED
@@ -159,6 +159,7 @@ import {
159
159
  chooseNext,
160
160
  edgeAncestors as edgeAncestorsFn,
161
161
  invalidateStructuralCaches,
162
+ leadsSomewhere,
162
163
  } from "./traverse.js";
163
164
  import { invalidateJunctionCache } from "./junction.js";
164
165
  import { follow } from "./match.js";
@@ -343,6 +344,14 @@ export class Mind implements MindContext {
343
344
  return resolveImpl(this, bytes);
344
345
  }
345
346
 
347
+ /** Whether a node leads somewhere — the admission predicate, delegating to
348
+ * `traverse.ts`'s ONE definition (edge or halo, with its response-scoped
349
+ * cache). The search holds a bare Store and cannot reach that cache itself,
350
+ * so it asks through this hook; a bare host keeps its raw-store fallback. */
351
+ leadsSomewhere(id: number): boolean {
352
+ return leadsSomewhere(this, id);
353
+ }
354
+
346
355
  // recogniseSpan wraps recognise
347
356
  recogniseSpan(bytes: Uint8Array): {
348
357
  sites: ReadonlyArray<Site>;
@@ -517,7 +517,7 @@ function recogniseImpl(ctx: MindContext, bytes: Uint8Array): Recognition {
517
517
  start: number,
518
518
  end: number,
519
519
  canonBudget: boolean,
520
- ): void => {
520
+ ): boolean => {
521
521
  // Any span at least one river window wide is worth a probe. This used
522
522
  // to stop at `chainReach(W)` — "the chain already covers anything that
523
523
  // short" — and that premise does not hold for every embedded form: the
@@ -531,14 +531,17 @@ function recogniseImpl(ctx: MindContext, bytes: Uint8Array): Recognition {
531
531
  // starts on a fold cut nor ends on a node edge was unreachable by either
532
532
  // tier — the exact site whose loss `tryChain`'s own note records as "the
533
533
  // pivot dies with the site and multi-hop goes silent". The interior
534
- // pass below spends the same budget on those pairs.
535
- if (end - start < W) return;
534
+ // pass below spends the same budget on those pairs. Returns whether it
535
+ // emitted, so a caller can retry a trimmed edge on the miss path only.
536
+ if (end - start < W) return false;
536
537
  if (flatProbe(start, end) === null) {
537
- if (!canonBudget) return;
538
- if (!canonAdmits(start, end)) return;
538
+ if (!canonBudget) return false;
539
+ if (!canonAdmits(start, end)) return false;
539
540
  }
540
541
  const id = resolveSpan(start, end);
541
- if (id !== null) emit(start, end, id);
542
+ if (id === null) return false;
543
+ emit(start, end, id);
544
+ return true;
542
545
  };
543
546
  // A CUMULATIVE BYTE BUDGET, SPENT SHORTEST-SPAN-FIRST.
544
547
  //
@@ -574,10 +577,7 @@ function recogniseImpl(ctx: MindContext, bytes: Uint8Array): Recognition {
574
577
  const span = end - start;
575
578
  const afford = span <= budget;
576
579
  if (afford) budget -= span;
577
- probe(start, end, afford);
578
- // Always keep walking: the exact route is unbudgeted, so running out
579
- // of canon budget must not stop the scan.
580
- return true;
580
+ return probe(start, end, afford);
581
581
  };
582
582
  const prefixes = ordered.filter((e) => e > 0).sort((a, b) => a - b);
583
583
  const suffixes = ordered
@@ -585,9 +585,24 @@ function recogniseImpl(ctx: MindContext, bytes: Uint8Array): Recognition {
585
585
  .sort((a, b) => b - a);
586
586
  for (let i = 0; i < Math.max(prefixes.length, suffixes.length); i++) {
587
587
  // Interleaved so neither edge starves the other when the budget runs
588
- // out — a query can carry a trained form at either end.
589
- if (i < prefixes.length && !spend(0, prefixes[i])) break;
590
- if (i < suffixes.length && !spend(suffixes[i], bytes.length)) break;
588
+ // out — a query can carry a trained form at either end. The scan never
589
+ // stops on a miss: the exact route is unbudgeted, so running out of
590
+ // canon budget must not stop it.
591
+ if (i < prefixes.length) spend(0, prefixes[i]);
592
+ if (i < suffixes.length) {
593
+ const s = suffixes[i];
594
+ // An edge form can end ONE byte before the edge does: a query often
595
+ // closes with a separator ("…is Timur Bekmambetov.") that belongs
596
+ // BETWEEN forms, and the text canonicalizer passes punctuation through,
597
+ // so the full-edge probe can never match it. Retry the trimmed edge
598
+ // ON THE MISS PATH ONLY — self-verifying (resolve decides, so a wrong
599
+ // trim can never emit), the same ±1 edge-trim discipline the canon-miss
600
+ // fallback above already trusts, and the hit path pays nothing. A form
601
+ // LONGER than `chainReach` at the sentence end (measured: "Timur
602
+ // Bekmambetov", 17 bytes) is recovered only here — the interior pass is
603
+ // capped at `chainReach`.
604
+ if (!spend(s, bytes.length)) spend(s, bytes.length - 1);
605
+ }
591
606
  }
592
607
  // INTERIOR pairs within the same `chainReach(W)` span bound the chain
593
608
  // trusts — the dead zone the gate above used to leave: a form that neither
package/src/mind/types.ts CHANGED
@@ -65,6 +65,12 @@ export interface GraphSearchHost {
65
65
  starts: ReadonlySet<number>;
66
66
  };
67
67
  chooseNext?(node: number): number | undefined;
68
+ /** The admission predicate — `traverse.ts`'s `leadsSomewhere`, its ONE
69
+ * definition: does this node bear an edge or a halo? Optional, so a bare
70
+ * host (a raw Store and nothing else) still works; when present, the search
71
+ * uses it rather than re-probing the store, which keeps the predicate
72
+ * single-defined AND memoised on the response-scoped struct cache. */
73
+ leadsSomewhere?(id: number): boolean;
68
74
  }
69
75
 
70
76
  // ═══════════════════════════════════════════════════════════════════════════
@@ -113,3 +113,36 @@ test("recognise(): an INTERIOR form at a non-cut offset is recovered, not only a
113
113
  assert.deepEqual([hit.start, hit.end], [23, 38]);
114
114
  await m.store.close();
115
115
  });
116
+
117
+ test("recognise(): a form LONGER than chainReach is recovered past a trailing separator", async () => {
118
+ // A 17-byte object (17 > chainReach(W) = 16) at the end of a sentence is
119
+ // outside the interior pass's span bound, and it is not a suffix of the span
120
+ // because a sentence-final separator (".") follows it — and the text
121
+ // canonicalizer passes punctuation through, so the full-edge probe can never
122
+ // match. MEASURED on the pre-change tree: no site for the object (only the
123
+ // shorter sub-form "Timur" surfaced); without the period the object WAS a
124
+ // suffix and was found. The edge tier now retries the trimmed edge on the
125
+ // MISS path only, so the hit path pays nothing.
126
+ const m = new Mind({ seed: 7, store: new SQliteStore({ path: ":memory:" }) });
127
+ await m.ingest([
128
+ ["xavier director", "The director of xavier is averyverylongname."],
129
+ ["averyverylongname", "The spouse of averyverylongname is zoe."],
130
+ ]);
131
+ const expected = resolve(m, enc("averyverylongname"));
132
+ assert.ok(
133
+ expected !== null,
134
+ "sanity: the object must resolve standalone",
135
+ );
136
+ assert.ok(
137
+ "averyverylongname".length > 16,
138
+ "sanity: the object must be longer than chainReach(W)=16",
139
+ );
140
+
141
+ const rec = recognise(m, enc("The director of xavier is averyverylongname."));
142
+ assert.ok(
143
+ rec.sites.some((s) => s.payload === expected),
144
+ `expected a site for the >chainReach object past the trailing separator, got: ` +
145
+ JSON.stringify(rec.sites.map((s) => [s.start, s.end])),
146
+ );
147
+ await m.store.close();
148
+ });
@@ -97,3 +97,41 @@ test("naming the intermediate does not need derive-through — the cover reads i
97
97
  );
98
98
  await store.close();
99
99
  });
100
+
101
+ /** The TRAP fixture: the query's OWN subject carries the asked relation too, so
102
+ * a join that follows the subject answers about the subject. */
103
+ const TRAP_CHAIN = [
104
+ ["Eiffel Tower country", "The country of Eiffel Tower is France."],
105
+ ["France capital", "The capital of France is Paris."],
106
+ ["France", "The capital of France is Paris."],
107
+ ["Eiffel Tower capital", "The capital of Eiffel Tower is Berlin."],
108
+ ["Eiffel Tower", "The country of Eiffel Tower is France."],
109
+ ];
110
+
111
+ async function trappedFlip() {
112
+ const store = new SQliteStore({ path: ":memory:", D: 1024 });
113
+ const mind = new Mind({ seed: 7, store });
114
+ await mind.ingest(TRAP_CHAIN);
115
+ await mind.ingest(Array.from({ length: 4300 }, (_, i) => filler(i)));
116
+ return { store, mind };
117
+ }
118
+
119
+ test("the join answers about the entity the query did NOT name, not the query's own subject", async () => {
120
+ // MEASURED on the pre-change tree: the answer was "The capital of Eiffel
121
+ // Tower country is Berlin." — the join followed the query's OWN subject
122
+ // (Eiffel Tower), which carries a "capital" fact of its own, instead of the
123
+ // entity the produced fact introduces (France). Both candidates are contexts
124
+ // joined by the same relation, so only the preference decides.
125
+ const { store, mind } = await trappedFlip();
126
+ const moves = [];
127
+ const out = await mind.respondText(
128
+ "Eiffel Tower country capital",
129
+ (s) => moves.push(s.mechanism[s.mechanism.length - 1]),
130
+ );
131
+ assert.equal(out.trim(), "The capital of France is Paris.");
132
+ assert.ok(
133
+ !out.includes("Eiffel Tower"),
134
+ `the answer must be about the inferred entity, got ${JSON.stringify(out)}`,
135
+ );
136
+ await store.close();
137
+ });