@hviana/sema 0.7.6 → 0.7.7
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.
- package/dist/src/mind/graph-search.d.ts +24 -3
- package/dist/src/mind/graph-search.js +80 -5
- package/dist/src/mind/recognition.js +35 -1
- package/dist/src/mind/trace.js +1 -0
- package/jsr.json +1 -1
- package/package.json +1 -1
- package/src/mind/graph-search.ts +93 -4
- package/src/mind/recognition.ts +33 -1
- package/src/mind/trace.ts +2 -0
- package/test/46-recognise-multibyte-edge.test.mjs +30 -0
- package/test/99-fact-join.test.mjs +97 -0
|
@@ -73,6 +73,12 @@ export type GItem = {
|
|
|
73
73
|
* derivation actually CHOSE. Part of {@link key}, because it decides
|
|
74
74
|
* whether the span's final bytes may still change. */
|
|
75
75
|
fix?: boolean;
|
|
76
|
+
/** Set on the out a JOIN produced: a produced fact's own contained entity
|
|
77
|
+
* (the subject the query never named) combined with the query's adjacent
|
|
78
|
+
* relation span named a learned key, and that key's continuation is this
|
|
79
|
+
* out. Part of {@link key} so the joined reading is a distinct chart item
|
|
80
|
+
* from the plain concatenation of the same bytes. */
|
|
81
|
+
join?: boolean;
|
|
76
82
|
};
|
|
77
83
|
export declare const STEP = 1;
|
|
78
84
|
export declare const CONCEPT = 10;
|
|
@@ -136,7 +142,7 @@ export interface DerivationItem {
|
|
|
136
142
|
* {@link GraphSearch}'s rules fired, recovered from the rule's premise/
|
|
137
143
|
* conclusion shape (the rules carry no label, so this classifies by structure,
|
|
138
144
|
* the single place that maps rule geometry to a name). */
|
|
139
|
-
export type DerivationMove = "axiom" | "follow-edge" | "concept-hop" | "voice" | "ground" | "splice-connector" | "split" | "fuse" | "recompose" | "bridge" | "pool-vote" | "step";
|
|
145
|
+
export type DerivationMove = "axiom" | "follow-edge" | "concept-hop" | "voice" | "ground" | "splice-connector" | "split" | "fuse" | "recompose" | "join-fact" | "bridge" | "pool-vote" | "step";
|
|
140
146
|
/** The lightest-derivation search over the Sema graph. One instance binds the
|
|
141
147
|
* store, `maxGroup` (the fusible span ceiling), and the canonical
|
|
142
148
|
* {@link resolve} callback; {@link cover} then solves one query. */
|
|
@@ -312,10 +318,25 @@ export declare class GraphSearch {
|
|
|
312
318
|
* most once per cover. A Set, not a flag, because it states WHICH node is
|
|
313
319
|
* open — the invariant a reader needs to check the guard. */
|
|
314
320
|
private recompleteOpen;
|
|
321
|
+
/** JOIN — the move the substrate was missing: derive the answer THROUGH a
|
|
322
|
+
* produced fact, without the intermediate key being named in the query.
|
|
323
|
+
*
|
|
324
|
+
* A produced fact (`fact.node`) carries the subject the query reached but
|
|
325
|
+
* never wrote; the query's remaining tail names the relation to follow from
|
|
326
|
+
* it. The pair IS a learned key — `"<entity><tail>"` — so the rule asks the
|
|
327
|
+
* store for that key's continuation and, when it exists, concludes with the
|
|
328
|
+
* joined fact. On the ladder it is one STEP: a direct edge, exactly as
|
|
329
|
+
* following a literal continuation is. Deterministic and point-probed
|
|
330
|
+
* (`resolve` + `nextFirst`, no scan), so it adds no read that grows with the
|
|
331
|
+
* corpus. The move is visible in the rationale as its own act
|
|
332
|
+
* (`classifyMove` reports `join-fact`), distinct from the byte-concatenating
|
|
333
|
+
* `fuse`/`splice`. */
|
|
334
|
+
private join;
|
|
315
335
|
/** out(i,j,bytes,…): index it for the binary rules, then offer splicing a
|
|
316
336
|
* learnt connector (the in-search bridge), splitting (at a sub-leaf form
|
|
317
|
-
* boundary), bridging (cover(i) ∧ this → cover(j)),
|
|
318
|
-
*
|
|
337
|
+
* boundary), bridging (cover(i) ∧ this → cover(j)), fusing with an adjacent
|
|
338
|
+
* finalised out, and — for a produced fact — JOINING the entity it contains
|
|
339
|
+
* with the query's tail ({@link join}). */
|
|
319
340
|
private outRules;
|
|
320
341
|
/** Whether the query span [from, to) is wholly covered by RECOGNISED outs —
|
|
321
342
|
* the test that lets a connector jump across INTERIOR answers (an N-ary whole)
|
|
@@ -101,8 +101,11 @@ function classifyMove(premises, conclusion, articulating) {
|
|
|
101
101
|
return "step";
|
|
102
102
|
return articulating ? "voice" : "ground";
|
|
103
103
|
}
|
|
104
|
-
if (p.kind === "out" && conclusion.kind === "out")
|
|
105
|
-
|
|
104
|
+
if (p.kind === "out" && conclusion.kind === "out") {
|
|
105
|
+
// A JOIN derives through a produced fact's own contained subject; a plain
|
|
106
|
+
// single-premise out→out is the byte-level split.
|
|
107
|
+
return conclusion.join ? "join-fact" : "split";
|
|
108
|
+
}
|
|
106
109
|
return "step";
|
|
107
110
|
}
|
|
108
111
|
if (premises.length === 2) {
|
|
@@ -325,6 +328,13 @@ export class GraphSearch {
|
|
|
325
328
|
// states the formula itself instead of importing it.
|
|
326
329
|
const atomsAreHubs = Math.max(1, Math.ceil((this.store.edgeSourceCount() * W) / 256)) > this.hubBound();
|
|
327
330
|
const nodeBytes = (n) => this.store.bytesPrefix(n, ALL);
|
|
331
|
+
// The query's own bytes, tiled from its perceived leaves. A JOIN reads the
|
|
332
|
+
// tail a produced fact's contained entity has to combine with, and `buildSearch`
|
|
333
|
+
// otherwise only ever sees positions, never the bytes behind them.
|
|
334
|
+
const queryBytes = new Uint8Array(queryLen);
|
|
335
|
+
for (const lf of leaves) {
|
|
336
|
+
queryBytes.set(lf.bytes.subarray(0, lf.end - lf.start), lf.start);
|
|
337
|
+
}
|
|
328
338
|
// Content-addressed probes over the store's hash-cons maps — the same keys
|
|
329
339
|
// training filled. No byte-by-byte trie walk.
|
|
330
340
|
const findLeafU = (b) => this.store.findLeaf(b) ?? undefined;
|
|
@@ -357,7 +367,7 @@ export class GraphSearch {
|
|
|
357
367
|
if (it.kind === "form") {
|
|
358
368
|
return `f${it.i}.${it.j}.${it.node}.${it.via ? 1 : 0}.${it.rcmp ? 1 : 0}`;
|
|
359
369
|
}
|
|
360
|
-
return `o${it.i}.${it.j}.${it.cover ? 1 : 0}.${it.rec ? 1 : 0}.${it.fix ? 1 : 0}.${it.node ?? -1}.${latin1(it.bytes)}`;
|
|
370
|
+
return `o${it.i}.${it.j}.${it.cover ? 1 : 0}.${it.rec ? 1 : 0}.${it.fix ? 1 : 0}.${it.join ? 1 : 0}.${it.node ?? -1}.${latin1(it.bytes)}`;
|
|
361
371
|
},
|
|
362
372
|
*axioms() {
|
|
363
373
|
yield { item: { kind: "cover", p: 0 }, cost: 0 };
|
|
@@ -465,6 +475,8 @@ export class GraphSearch {
|
|
|
465
475
|
findBranchU,
|
|
466
476
|
linksByLeft,
|
|
467
477
|
linksByRight,
|
|
478
|
+
queryBytes,
|
|
479
|
+
queryLen,
|
|
468
480
|
});
|
|
469
481
|
},
|
|
470
482
|
};
|
|
@@ -854,10 +866,62 @@ export class GraphSearch {
|
|
|
854
866
|
* most once per cover. A Set, not a flag, because it states WHICH node is
|
|
855
867
|
* open — the invariant a reader needs to check the guard. */
|
|
856
868
|
recompleteOpen = new Set();
|
|
869
|
+
/** JOIN — the move the substrate was missing: derive the answer THROUGH a
|
|
870
|
+
* produced fact, without the intermediate key being named in the query.
|
|
871
|
+
*
|
|
872
|
+
* A produced fact (`fact.node`) carries the subject the query reached but
|
|
873
|
+
* never wrote; the query's remaining tail names the relation to follow from
|
|
874
|
+
* it. The pair IS a learned key — `"<entity><tail>"` — so the rule asks the
|
|
875
|
+
* store for that key's continuation and, when it exists, concludes with the
|
|
876
|
+
* joined fact. On the ladder it is one STEP: a direct edge, exactly as
|
|
877
|
+
* following a literal continuation is. Deterministic and point-probed
|
|
878
|
+
* (`resolve` + `nextFirst`, no scan), so it adds no read that grows with the
|
|
879
|
+
* corpus. The move is visible in the rationale as its own act
|
|
880
|
+
* (`classifyMove` reports `join-fact`), distinct from the byte-concatenating
|
|
881
|
+
* `fuse`/`splice`. */
|
|
882
|
+
*join(fact, queryBytes, queryLen) {
|
|
883
|
+
if (!this.host.recogniseSpan)
|
|
884
|
+
return;
|
|
885
|
+
const tail = queryBytes.subarray(fact.j, queryLen);
|
|
886
|
+
if (tail.length === 0)
|
|
887
|
+
return;
|
|
888
|
+
// The entity candidates are the forms the fact's own bytes CONTAIN — the
|
|
889
|
+
// same recogniser the query went through, so the evidence standard is the
|
|
890
|
+
// query's. A byte atom is never a subject; the fact's own node is the span
|
|
891
|
+
// itself, not an entity inside it.
|
|
892
|
+
for (const site of this.host.recogniseSpan(fact.bytes).sites) {
|
|
893
|
+
if (site.payload < 0 || site.payload === fact.node)
|
|
894
|
+
continue;
|
|
895
|
+
if (!this.store.hasNext(site.payload) &&
|
|
896
|
+
!this.store.hasHalo(site.payload))
|
|
897
|
+
continue;
|
|
898
|
+
const key = this.host.resolve(concat2(this.store.bytesPrefix(site.payload, ALL), tail));
|
|
899
|
+
if (key === null)
|
|
900
|
+
continue;
|
|
901
|
+
const nx = this.store.nextFirst(key, 1);
|
|
902
|
+
if (nx.length === 0)
|
|
903
|
+
continue;
|
|
904
|
+
yield {
|
|
905
|
+
premises: [fact],
|
|
906
|
+
conclusion: {
|
|
907
|
+
kind: "out",
|
|
908
|
+
i: fact.i,
|
|
909
|
+
j: queryLen,
|
|
910
|
+
bytes: this.store.bytesPrefix(nx[0], ALL),
|
|
911
|
+
cover: true,
|
|
912
|
+
rec: true,
|
|
913
|
+
node: nx[0],
|
|
914
|
+
join: true,
|
|
915
|
+
},
|
|
916
|
+
cost: STEP,
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
}
|
|
857
920
|
/** out(i,j,bytes,…): index it for the binary rules, then offer splicing a
|
|
858
921
|
* learnt connector (the in-search bridge), splitting (at a sub-leaf form
|
|
859
|
-
* boundary), bridging (cover(i) ∧ this → cover(j)),
|
|
860
|
-
*
|
|
922
|
+
* boundary), bridging (cover(i) ∧ this → cover(j)), fusing with an adjacent
|
|
923
|
+
* finalised out, and — for a produced fact — JOINING the entity it contains
|
|
924
|
+
* with the query's tail ({@link join}). */
|
|
861
925
|
*outRules(it, ctx) {
|
|
862
926
|
const { splits, coversDone, outsByStart, outsByEnd, coverableByStart } = ctx;
|
|
863
927
|
const outsByNode = ctx.outsByNode;
|
|
@@ -950,6 +1014,17 @@ export class GraphSearch {
|
|
|
950
1014
|
yield* this.fuse(it, r, ctx);
|
|
951
1015
|
for (const l of outsByEnd.get(it.i) ?? [])
|
|
952
1016
|
yield* this.fuse(l, it, ctx);
|
|
1017
|
+
// ── JOIN (the A*LD extension) ───────────────────────────────────────
|
|
1018
|
+
// A produced fact may CONTAIN the subject the query never named; the query's
|
|
1019
|
+
// remaining tail then names the relation to follow FROM that subject. The
|
|
1020
|
+
// pair (contained entity, tail) is itself a learned key, and its
|
|
1021
|
+
// continuation is the derived answer — a genuine two-fact join, not the
|
|
1022
|
+
// juxtaposition the cover produces when the intermediate key IS named.
|
|
1023
|
+
// Fired per finalized out with a node, so it is the search's own rule, on
|
|
1024
|
+
// the ladder, memoised by {@link key}, and bounded by the fact's own length.
|
|
1025
|
+
if (it.node !== undefined) {
|
|
1026
|
+
yield* this.join(it, ctx.queryBytes, ctx.queryLen);
|
|
1027
|
+
}
|
|
953
1028
|
}
|
|
954
1029
|
/** Whether the query span [from, to) is wholly covered by RECOGNISED outs —
|
|
955
1030
|
* the test that lets a connector jump across INTERIOR answers (an N-ary whole)
|
|
@@ -501,7 +501,21 @@ function recogniseImpl(ctx, bytes) {
|
|
|
501
501
|
// embedded differently-cased form needed 64x the budget to be found,
|
|
502
502
|
// while the exact route it was competing with needed none of it.
|
|
503
503
|
const probe = (start, end, canonBudget) => {
|
|
504
|
-
|
|
504
|
+
// Any span at least one river window wide is worth a probe. This used
|
|
505
|
+
// to stop at `chainReach(W)` — "the chain already covers anything that
|
|
506
|
+
// short" — and that premise is false for a NESTED form: the chain grows
|
|
507
|
+
// single-byte leaf ids and gates each step on `findBranch(ids)`, which
|
|
508
|
+
// is null for a form the write side chunked (measured: "Gustaf
|
|
509
|
+
// Molander" embedded in "The director of Eva is Gustaf Molander." gains
|
|
510
|
+
// no branch at any prefix, so `resolveSpan` is never reached), and its
|
|
511
|
+
// INTERIOR reach is one chunk plus W, which can be shorter than the
|
|
512
|
+
// form. The result was a dead zone: a form shorter than `chainReach`
|
|
513
|
+
// that neither starts on a fold cut nor ends on a node edge was
|
|
514
|
+
// unreachable by either tier — the exact site whose loss `tryChain`'s
|
|
515
|
+
// own note records as "the pivot dies with the site and multi-hop goes
|
|
516
|
+
// silent". The interior pass below spends the same budget on those
|
|
517
|
+
// pairs.
|
|
518
|
+
if (end - start < W)
|
|
505
519
|
return;
|
|
506
520
|
if (flatProbe(start, end) === null) {
|
|
507
521
|
if (!canonBudget)
|
|
@@ -565,6 +579,26 @@ function recogniseImpl(ctx, bytes) {
|
|
|
565
579
|
if (i < suffixes.length && !spend(suffixes[i], bytes.length))
|
|
566
580
|
break;
|
|
567
581
|
}
|
|
582
|
+
// INTERIOR pairs, bounded by the same `chainReach(W)` the chain trusts —
|
|
583
|
+
// the dead zone the gate above used to leave: a form that neither starts
|
|
584
|
+
// on a fold cut nor ends on a node edge is exactly the one neither the
|
|
585
|
+
// chain (nested, `findBranch` misses) nor the two edge scans reach. The
|
|
586
|
+
// pair count is `|endpoints| · chainReach(W)`, i.e. LINEAR in the query —
|
|
587
|
+
// the W² span bound is what keeps this from being the quadratic scan the
|
|
588
|
+
// budget note above describes (that one had no span bound at all).
|
|
589
|
+
{
|
|
590
|
+
const reach = chainReach(W);
|
|
591
|
+
for (const end of ordered) {
|
|
592
|
+
for (const start of ordered) {
|
|
593
|
+
if (start >= end)
|
|
594
|
+
continue;
|
|
595
|
+
const span = end - start;
|
|
596
|
+
if (span < W || span > reach)
|
|
597
|
+
continue;
|
|
598
|
+
spend(start, end);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
568
602
|
}
|
|
569
603
|
}
|
|
570
604
|
const chunkEnd = new Uint32Array(bytes.length);
|
package/dist/src/mind/trace.js
CHANGED
|
@@ -50,6 +50,7 @@ export const MOVE_NOTE = {
|
|
|
50
50
|
"split": "cut a span at a sub-leaf form boundary so a form can be reached",
|
|
51
51
|
"fuse": "fuse adjacent fragments toward a deeper learned form",
|
|
52
52
|
"recompose": "recompose fused parts into a learned whole that leads on",
|
|
53
|
+
"join-fact": "join a produced fact's own subject with the query's tail — derive through the fact, not alongside it",
|
|
53
54
|
"bridge": "advance the cover frontier across this span",
|
|
54
55
|
"pool-vote": "pool independent regions' evidence for a shared anchor (sum, not shortest path)",
|
|
55
56
|
"axiom": "a seed: a perceived leaf, recognised form, or computed result",
|
package/jsr.json
CHANGED
package/package.json
CHANGED
package/src/mind/graph-search.ts
CHANGED
|
@@ -115,6 +115,12 @@ export type GItem =
|
|
|
115
115
|
* derivation actually CHOSE. Part of {@link key}, because it decides
|
|
116
116
|
* whether the span's final bytes may still change. */
|
|
117
117
|
fix?: boolean;
|
|
118
|
+
/** Set on the out a JOIN produced: a produced fact's own contained entity
|
|
119
|
+
* (the subject the query never named) combined with the query's adjacent
|
|
120
|
+
* relation span named a learned key, and that key's continuation is this
|
|
121
|
+
* out. Part of {@link key} so the joined reading is a distinct chart item
|
|
122
|
+
* from the plain concatenation of the same bytes. */
|
|
123
|
+
join?: boolean;
|
|
118
124
|
};
|
|
119
125
|
type OutItem = Extract<GItem, { kind: "out" }>;
|
|
120
126
|
|
|
@@ -240,6 +246,7 @@ export type DerivationMove =
|
|
|
240
246
|
| "split" // out→out cut at a sub-leaf form boundary
|
|
241
247
|
| "fuse" // out+out→out: adjacent fragments recomposed toward a learned form
|
|
242
248
|
| "recompose" // out+out→form: a fused pair that names an edge-bearing node
|
|
249
|
+
| "join-fact" // out→out: a produced fact's own contained subject + the query's tail names a learned key (the join)
|
|
243
250
|
| "bridge" // cover+out→cover: the cover frontier advanced across a span
|
|
244
251
|
| "pool-vote" // N premises→conclusion, evidence pooled (combine:"sum" — see derive)
|
|
245
252
|
| "step"; // any other single-premise move (fallback)
|
|
@@ -276,7 +283,11 @@ function classifyMove(
|
|
|
276
283
|
if (!conclusion.rec) return "step";
|
|
277
284
|
return articulating ? "voice" : "ground";
|
|
278
285
|
}
|
|
279
|
-
if (p.kind === "out" && conclusion.kind === "out")
|
|
286
|
+
if (p.kind === "out" && conclusion.kind === "out") {
|
|
287
|
+
// A JOIN derives through a produced fact's own contained subject; a plain
|
|
288
|
+
// single-premise out→out is the byte-level split.
|
|
289
|
+
return conclusion.join ? "join-fact" : "split";
|
|
290
|
+
}
|
|
280
291
|
return "step";
|
|
281
292
|
}
|
|
282
293
|
if (premises.length === 2) {
|
|
@@ -551,6 +562,13 @@ export class GraphSearch {
|
|
|
551
562
|
Math.ceil((this.store.edgeSourceCount() * W) / 256),
|
|
552
563
|
) > this.hubBound();
|
|
553
564
|
const nodeBytes = (n: number) => this.store.bytesPrefix(n, ALL);
|
|
565
|
+
// The query's own bytes, tiled from its perceived leaves. A JOIN reads the
|
|
566
|
+
// tail a produced fact's contained entity has to combine with, and `buildSearch`
|
|
567
|
+
// otherwise only ever sees positions, never the bytes behind them.
|
|
568
|
+
const queryBytes = new Uint8Array(queryLen);
|
|
569
|
+
for (const lf of leaves) {
|
|
570
|
+
queryBytes.set(lf.bytes.subarray(0, lf.end - lf.start), lf.start);
|
|
571
|
+
}
|
|
554
572
|
// Content-addressed probes over the store's hash-cons maps — the same keys
|
|
555
573
|
// training filled. No byte-by-byte trie walk.
|
|
556
574
|
const findLeafU = (b: Uint8Array) => this.store.findLeaf(b) ?? undefined;
|
|
@@ -589,7 +607,7 @@ export class GraphSearch {
|
|
|
589
607
|
}
|
|
590
608
|
return `o${it.i}.${it.j}.${it.cover ? 1 : 0}.${it.rec ? 1 : 0}.${
|
|
591
609
|
it.fix ? 1 : 0
|
|
592
|
-
}.${it.node ?? -1}.${latin1(it.bytes)}`;
|
|
610
|
+
}.${it.join ? 1 : 0}.${it.node ?? -1}.${latin1(it.bytes)}`;
|
|
593
611
|
},
|
|
594
612
|
*axioms() {
|
|
595
613
|
yield { item: { kind: "cover", p: 0 }, cost: 0 };
|
|
@@ -697,6 +715,8 @@ export class GraphSearch {
|
|
|
697
715
|
findBranchU,
|
|
698
716
|
linksByLeft,
|
|
699
717
|
linksByRight,
|
|
718
|
+
queryBytes,
|
|
719
|
+
queryLen,
|
|
700
720
|
});
|
|
701
721
|
},
|
|
702
722
|
};
|
|
@@ -1113,10 +1133,65 @@ export class GraphSearch {
|
|
|
1113
1133
|
* open — the invariant a reader needs to check the guard. */
|
|
1114
1134
|
private recompleteOpen = new Set<number>();
|
|
1115
1135
|
|
|
1136
|
+
/** JOIN — the move the substrate was missing: derive the answer THROUGH a
|
|
1137
|
+
* produced fact, without the intermediate key being named in the query.
|
|
1138
|
+
*
|
|
1139
|
+
* A produced fact (`fact.node`) carries the subject the query reached but
|
|
1140
|
+
* never wrote; the query's remaining tail names the relation to follow from
|
|
1141
|
+
* it. The pair IS a learned key — `"<entity><tail>"` — so the rule asks the
|
|
1142
|
+
* store for that key's continuation and, when it exists, concludes with the
|
|
1143
|
+
* joined fact. On the ladder it is one STEP: a direct edge, exactly as
|
|
1144
|
+
* following a literal continuation is. Deterministic and point-probed
|
|
1145
|
+
* (`resolve` + `nextFirst`, no scan), so it adds no read that grows with the
|
|
1146
|
+
* corpus. The move is visible in the rationale as its own act
|
|
1147
|
+
* (`classifyMove` reports `join-fact`), distinct from the byte-concatenating
|
|
1148
|
+
* `fuse`/`splice`. */
|
|
1149
|
+
private *join(
|
|
1150
|
+
fact: OutItem,
|
|
1151
|
+
queryBytes: Uint8Array,
|
|
1152
|
+
queryLen: number,
|
|
1153
|
+
): Iterable<Rule<GItem>> {
|
|
1154
|
+
if (!this.host.recogniseSpan) return;
|
|
1155
|
+
const tail = queryBytes.subarray(fact.j, queryLen);
|
|
1156
|
+
if (tail.length === 0) return;
|
|
1157
|
+
// The entity candidates are the forms the fact's own bytes CONTAIN — the
|
|
1158
|
+
// same recogniser the query went through, so the evidence standard is the
|
|
1159
|
+
// query's. A byte atom is never a subject; the fact's own node is the span
|
|
1160
|
+
// itself, not an entity inside it.
|
|
1161
|
+
for (const site of this.host.recogniseSpan(fact.bytes).sites) {
|
|
1162
|
+
if (site.payload < 0 || site.payload === fact.node) continue;
|
|
1163
|
+
if (
|
|
1164
|
+
!this.store.hasNext(site.payload) &&
|
|
1165
|
+
!this.store.hasHalo(site.payload)
|
|
1166
|
+
) continue;
|
|
1167
|
+
const key = this.host.resolve(
|
|
1168
|
+
concat2(this.store.bytesPrefix(site.payload, ALL), tail),
|
|
1169
|
+
);
|
|
1170
|
+
if (key === null) continue;
|
|
1171
|
+
const nx = this.store.nextFirst(key, 1);
|
|
1172
|
+
if (nx.length === 0) continue;
|
|
1173
|
+
yield {
|
|
1174
|
+
premises: [fact],
|
|
1175
|
+
conclusion: {
|
|
1176
|
+
kind: "out",
|
|
1177
|
+
i: fact.i,
|
|
1178
|
+
j: queryLen,
|
|
1179
|
+
bytes: this.store.bytesPrefix(nx[0], ALL),
|
|
1180
|
+
cover: true,
|
|
1181
|
+
rec: true,
|
|
1182
|
+
node: nx[0],
|
|
1183
|
+
join: true,
|
|
1184
|
+
},
|
|
1185
|
+
cost: STEP,
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1116
1190
|
/** out(i,j,bytes,…): index it for the binary rules, then offer splicing a
|
|
1117
1191
|
* learnt connector (the in-search bridge), splitting (at a sub-leaf form
|
|
1118
|
-
* boundary), bridging (cover(i) ∧ this → cover(j)),
|
|
1119
|
-
*
|
|
1192
|
+
* boundary), bridging (cover(i) ∧ this → cover(j)), fusing with an adjacent
|
|
1193
|
+
* finalised out, and — for a produced fact — JOINING the entity it contains
|
|
1194
|
+
* with the query's tail ({@link join}). */
|
|
1120
1195
|
private *outRules(
|
|
1121
1196
|
it: OutItem,
|
|
1122
1197
|
ctx: {
|
|
@@ -1133,6 +1208,8 @@ export class GraphSearch {
|
|
|
1133
1208
|
findBranchU: (k: number[]) => number | undefined;
|
|
1134
1209
|
linksByLeft?: ReadonlyMap<number, Array<[number, Uint8Array]>>;
|
|
1135
1210
|
linksByRight?: ReadonlyMap<number, Array<[number, Uint8Array]>>;
|
|
1211
|
+
queryBytes: Uint8Array;
|
|
1212
|
+
queryLen: number;
|
|
1136
1213
|
},
|
|
1137
1214
|
): Iterable<Rule<GItem>> {
|
|
1138
1215
|
const { splits, coversDone, outsByStart, outsByEnd, coverableByStart } =
|
|
@@ -1225,6 +1302,18 @@ export class GraphSearch {
|
|
|
1225
1302
|
|
|
1226
1303
|
for (const r of outsByStart.get(it.j) ?? []) yield* this.fuse(it, r, ctx);
|
|
1227
1304
|
for (const l of outsByEnd.get(it.i) ?? []) yield* this.fuse(l, it, ctx);
|
|
1305
|
+
|
|
1306
|
+
// ── JOIN (the A*LD extension) ───────────────────────────────────────
|
|
1307
|
+
// A produced fact may CONTAIN the subject the query never named; the query's
|
|
1308
|
+
// remaining tail then names the relation to follow FROM that subject. The
|
|
1309
|
+
// pair (contained entity, tail) is itself a learned key, and its
|
|
1310
|
+
// continuation is the derived answer — a genuine two-fact join, not the
|
|
1311
|
+
// juxtaposition the cover produces when the intermediate key IS named.
|
|
1312
|
+
// Fired per finalized out with a node, so it is the search's own rule, on
|
|
1313
|
+
// the ladder, memoised by {@link key}, and bounded by the fact's own length.
|
|
1314
|
+
if (it.node !== undefined) {
|
|
1315
|
+
yield* this.join(it, ctx.queryBytes, ctx.queryLen);
|
|
1316
|
+
}
|
|
1228
1317
|
}
|
|
1229
1318
|
|
|
1230
1319
|
/** Whether the query span [from, to) is wholly covered by RECOGNISED outs —
|
package/src/mind/recognition.ts
CHANGED
|
@@ -518,7 +518,21 @@ function recogniseImpl(ctx: MindContext, bytes: Uint8Array): Recognition {
|
|
|
518
518
|
end: number,
|
|
519
519
|
canonBudget: boolean,
|
|
520
520
|
): void => {
|
|
521
|
-
|
|
521
|
+
// Any span at least one river window wide is worth a probe. This used
|
|
522
|
+
// to stop at `chainReach(W)` — "the chain already covers anything that
|
|
523
|
+
// short" — and that premise is false for a NESTED form: the chain grows
|
|
524
|
+
// single-byte leaf ids and gates each step on `findBranch(ids)`, which
|
|
525
|
+
// is null for a form the write side chunked (measured: "Gustaf
|
|
526
|
+
// Molander" embedded in "The director of Eva is Gustaf Molander." gains
|
|
527
|
+
// no branch at any prefix, so `resolveSpan` is never reached), and its
|
|
528
|
+
// INTERIOR reach is one chunk plus W, which can be shorter than the
|
|
529
|
+
// form. The result was a dead zone: a form shorter than `chainReach`
|
|
530
|
+
// that neither starts on a fold cut nor ends on a node edge was
|
|
531
|
+
// unreachable by either tier — the exact site whose loss `tryChain`'s
|
|
532
|
+
// own note records as "the pivot dies with the site and multi-hop goes
|
|
533
|
+
// silent". The interior pass below spends the same budget on those
|
|
534
|
+
// pairs.
|
|
535
|
+
if (end - start < W) return;
|
|
522
536
|
if (flatProbe(start, end) === null) {
|
|
523
537
|
if (!canonBudget) return;
|
|
524
538
|
if (!canonAdmits(start, end)) return;
|
|
@@ -575,6 +589,24 @@ function recogniseImpl(ctx: MindContext, bytes: Uint8Array): Recognition {
|
|
|
575
589
|
if (i < prefixes.length && !spend(0, prefixes[i])) break;
|
|
576
590
|
if (i < suffixes.length && !spend(suffixes[i], bytes.length)) break;
|
|
577
591
|
}
|
|
592
|
+
// INTERIOR pairs, bounded by the same `chainReach(W)` the chain trusts —
|
|
593
|
+
// the dead zone the gate above used to leave: a form that neither starts
|
|
594
|
+
// on a fold cut nor ends on a node edge is exactly the one neither the
|
|
595
|
+
// chain (nested, `findBranch` misses) nor the two edge scans reach. The
|
|
596
|
+
// pair count is `|endpoints| · chainReach(W)`, i.e. LINEAR in the query —
|
|
597
|
+
// the W² span bound is what keeps this from being the quadratic scan the
|
|
598
|
+
// budget note above describes (that one had no span bound at all).
|
|
599
|
+
{
|
|
600
|
+
const reach = chainReach(W);
|
|
601
|
+
for (const end of ordered) {
|
|
602
|
+
for (const start of ordered) {
|
|
603
|
+
if (start >= end) continue;
|
|
604
|
+
const span = end - start;
|
|
605
|
+
if (span < W || span > reach) continue;
|
|
606
|
+
spend(start, end);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
578
610
|
}
|
|
579
611
|
}
|
|
580
612
|
|
package/src/mind/trace.ts
CHANGED
|
@@ -75,6 +75,8 @@ export const MOVE_NOTE: Record<string, string> = {
|
|
|
75
75
|
"split": "cut a span at a sub-leaf form boundary so a form can be reached",
|
|
76
76
|
"fuse": "fuse adjacent fragments toward a deeper learned form",
|
|
77
77
|
"recompose": "recompose fused parts into a learned whole that leads on",
|
|
78
|
+
"join-fact":
|
|
79
|
+
"join a produced fact's own subject with the query's tail — derive through the fact, not alongside it",
|
|
78
80
|
"bridge": "advance the cover frontier across this span",
|
|
79
81
|
"pool-vote":
|
|
80
82
|
"pool independent regions' evidence for a shared anchor (sum, not shortest path)",
|
|
@@ -83,3 +83,33 @@ test("recognise(): a wide edge trim does not corrupt an unrelated short-form ans
|
|
|
83
83
|
const r = await m.respond("2+2 は何ですか");
|
|
84
84
|
assert.equal(dec(r.bytes), "4");
|
|
85
85
|
});
|
|
86
|
+
|
|
87
|
+
test("recognise(): an INTERIOR form at a non-cut offset is recovered, not only an edge one", async () => {
|
|
88
|
+
// The edge tier used to probe only prefixes of 0 and suffixes to bytes.length,
|
|
89
|
+
// and the flat-leaf chain cannot rebuild a write-side-chunked form (its
|
|
90
|
+
// `findBranch(ids)` pre-check misses at every prefix, so `resolveSpan` is
|
|
91
|
+
// never reached) while its interior reach is one chunk plus W. A form that
|
|
92
|
+
// neither starts on a fold cut nor ends on a node edge therefore fell in a
|
|
93
|
+
// dead zone — exactly the object inside a produced fact ("…is Gustaf
|
|
94
|
+
// Molander."), which is the site the pivot would need to chain on.
|
|
95
|
+
// MEASURED on the pre-change tree: no site for the entity. With the bounded
|
|
96
|
+
// interior pass (spans W..chainReach(W), linear in the query), it is found at
|
|
97
|
+
// its true span.
|
|
98
|
+
const m = new Mind({ seed: 7, store: new SQliteStore({ path: ":memory:" }) });
|
|
99
|
+
await m.ingest([
|
|
100
|
+
["x", "The director of Eva is Gustaf Molander."],
|
|
101
|
+
["Gustaf Molander", "The father of Gustaf Molander is Harald Molander."],
|
|
102
|
+
]);
|
|
103
|
+
const expected = resolve(m, enc("Gustaf Molander"));
|
|
104
|
+
assert.ok(expected !== null, "sanity: the entity must resolve standalone");
|
|
105
|
+
|
|
106
|
+
const rec = recognise(m, enc("The director of Eva is Gustaf Molander."));
|
|
107
|
+
const hit = rec.sites.find((s) => s.payload === expected);
|
|
108
|
+
assert.ok(
|
|
109
|
+
hit,
|
|
110
|
+
`expected an interior site for the entity, got: ` +
|
|
111
|
+
JSON.stringify(rec.sites.map((s) => [s.start, s.end, s.payload])),
|
|
112
|
+
);
|
|
113
|
+
assert.deepEqual([hit.start, hit.end], [23, 38]);
|
|
114
|
+
await m.store.close();
|
|
115
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// 99-fact-join.test.mjs — the JOIN: derive the answer THROUGH a produced fact
|
|
2
|
+
// whose subject the query never names.
|
|
3
|
+
//
|
|
4
|
+
// THE MOVE THIS PINS. A query like "Eiffel Tower country capital" names hop 1's
|
|
5
|
+
// key ("Eiffel Tower country") and hop 2's relation (" capital"), but never the
|
|
6
|
+
// intermediate subject ("France"). The cover alone can only juxtapose the two
|
|
7
|
+
// facts; the pivot needs the subject to surface as an unconsumed site inside the
|
|
8
|
+
// produced fact. The `join` rule in graph-search.ts closes the gap: it takes the
|
|
9
|
+
// entity the produced fact CONTAINS, combines it with the query's remaining tail,
|
|
10
|
+
// and asks the store for that key's continuation — a direct STEP on the ladder,
|
|
11
|
+
// reported as `join-fact` in the rationale.
|
|
12
|
+
//
|
|
13
|
+
// WHY THE FIXTURE CROSSES N=4096. Below the atomIsHub flip the interior subject
|
|
14
|
+
// surfaces anyway (small-store recognition) and the PIVOT alone reaches the
|
|
15
|
+
// chain, so a small fixture passes under the pre-change tree too — measured on a
|
|
16
|
+
// 3-deposit store, which answered "The father of Gustaf Molander is Harald
|
|
17
|
+
// Molander." with no join. The join is what carries the chain at corpus scale,
|
|
18
|
+
// so the fixture must sit past the flip, exactly as test/78 does. The filler is
|
|
19
|
+
// lexically varied for the same reason test/78's is: a templated corpus folds to
|
|
20
|
+
// shared chunks and leaves the query uncontested.
|
|
21
|
+
|
|
22
|
+
import { test } from "node:test";
|
|
23
|
+
import assert from "node:assert/strict";
|
|
24
|
+
import { Mind } from "../dist/src/index.js";
|
|
25
|
+
import { SQliteStore } from "../dist/src/store-sqlite.js";
|
|
26
|
+
|
|
27
|
+
const CHAIN = [
|
|
28
|
+
["Eiffel Tower country", "The country of Eiffel Tower is France."],
|
|
29
|
+
["France capital", "The capital of France is Paris."],
|
|
30
|
+
// The pivot fact: the intermediate subject as a context of its own.
|
|
31
|
+
["France", "The capital of France is Paris."],
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const WORDS =
|
|
35
|
+
("alpha bravo charlie delta echo foxtrot golf hotel india juliet " +
|
|
36
|
+
"kilo lima mike november oscar papa quebec romeo sierra tango uniform " +
|
|
37
|
+
"victor whiskey xray yankee zulu amber bronze copper dahlia ember fjord " +
|
|
38
|
+
"gossamer harbour indigo jasmine kestrel lantern marigold nectar opal " +
|
|
39
|
+
"pewter quartz ripple saffron thistle umber violet willow xenon yarrow")
|
|
40
|
+
.split(" ");
|
|
41
|
+
const filler = (i) => {
|
|
42
|
+
const w = (n) => WORDS[(i * 7 + n * 13) % WORDS.length];
|
|
43
|
+
return [
|
|
44
|
+
`${w(1)} ${w(2)} ${w(3)} ${i}`,
|
|
45
|
+
`${w(4)} ${w(5)} ${w(6)} ${w(7)} ${i}`,
|
|
46
|
+
];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** One store, ingested past the atomIsHub flip. */
|
|
50
|
+
async function pastTheFlip() {
|
|
51
|
+
const store = new SQliteStore({ path: ":memory:", D: 1024 });
|
|
52
|
+
const mind = new Mind({ seed: 7, store });
|
|
53
|
+
await mind.ingest(CHAIN);
|
|
54
|
+
await mind.ingest(Array.from({ length: 4300 }, (_, i) => filler(i)));
|
|
55
|
+
return { store, mind };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test("a join derives through a produced fact whose subject the query never names", async () => {
|
|
59
|
+
const { store, mind } = await pastTheFlip();
|
|
60
|
+
const query = "Eiffel Tower country capital";
|
|
61
|
+
assert.ok(
|
|
62
|
+
!query.includes("France") && !query.includes("capital of France"),
|
|
63
|
+
"sanity: the intermediate subject must NOT be named in the query",
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const moves = [];
|
|
67
|
+
const out = await mind.respondText(
|
|
68
|
+
query,
|
|
69
|
+
(s) => moves.push(s.mechanism[s.mechanism.length - 1]),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
assert.equal(out.trim(), "The capital of France is Paris.");
|
|
73
|
+
assert.ok(
|
|
74
|
+
moves.includes("join-fact"),
|
|
75
|
+
`expected the join to be the move that reached the answer, got: ${
|
|
76
|
+
[...new Set(moves)].join(", ")
|
|
77
|
+
}`,
|
|
78
|
+
);
|
|
79
|
+
await store.close();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("naming the intermediate does not need the join — the cover reads it directly", async () => {
|
|
83
|
+
// The contrast that keeps the move honest: when the subject IS written, the
|
|
84
|
+
// cover already reaches the chain, so no join is claimed.
|
|
85
|
+
const { store, mind } = await pastTheFlip();
|
|
86
|
+
const moves = [];
|
|
87
|
+
const out = await mind.respondText(
|
|
88
|
+
"Eiffel Tower country France capital",
|
|
89
|
+
(s) => moves.push(s.mechanism[s.mechanism.length - 1]),
|
|
90
|
+
);
|
|
91
|
+
assert.ok(out.includes("The capital of France is Paris."));
|
|
92
|
+
assert.ok(
|
|
93
|
+
!moves.includes("join-fact"),
|
|
94
|
+
"a named intermediate must not be reported as a join",
|
|
95
|
+
);
|
|
96
|
+
await store.close();
|
|
97
|
+
});
|