@hviana/sema 0.7.9 → 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,22 +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
- // The admission predicate has ONE definition — `traverse.ts`'s
902
- // `leadsSomewhere` (edge or halo). The host LENDS it when it can (Mind
903
- // does, with the response-scoped struct cache); a bare host falls back to
904
- // the raw-store probe, so the search stays host-based. Same semantics
905
- // either way; the hook spares the repeated probe and keeps the predicate
906
- // from being redefined here.
907
- const leads = this.host.leadsSomewhere !== undefined
908
- ? this.host.leadsSomewhere(site.payload)
909
- : this.store.hasNext(site.payload) || this.store.hasHalo(site.payload);
910
- if (!leads)
911
- continue;
912
- 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));
913
925
  if (key === null)
914
926
  continue;
915
927
  const nx = this.store.nextFirst(key, 1);
@@ -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
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.9",
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.9",
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,22 +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
- // The admission predicate has ONE definition — `traverse.ts`'s
1172
- // `leadsSomewhere` (edge or halo). The host LENDS it when it can (Mind
1173
- // does, with the response-scoped struct cache); a bare host falls back to
1174
- // the raw-store probe, so the search stays host-based. Same semantics
1175
- // either way; the hook spares the repeated probe and keeps the predicate
1176
- // from being redefined here.
1177
- const leads = this.host.leadsSomewhere !== undefined
1178
- ? this.host.leadsSomewhere(site.payload)
1179
- : this.store.hasNext(site.payload) || this.store.hasHalo(site.payload);
1180
- if (!leads) continue;
1181
- const key = this.host.resolve(
1182
- 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
+ )
1183
1199
  );
1200
+ for (const c of candidates) {
1201
+ const key = this.host.resolve(concat2(c.bytes, tail));
1184
1202
  if (key === null) continue;
1185
1203
  const nx = this.store.nextFirst(key, 1);
1186
1204
  if (nx.length === 0) continue;
@@ -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
@@ -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
+ });