@hviana/sema 0.4.4 → 0.4.6
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/AUTHORS.md +0 -1
- package/LICENSE.md +1 -1
- package/README.md +2 -2
- package/dist/src/geometry.d.ts +6 -0
- package/dist/src/geometry.js +224 -44
- package/dist/src/mind/attention.d.ts +11 -0
- package/dist/src/mind/attention.js +344 -13
- package/dist/src/mind/junction.js +18 -2
- package/dist/src/mind/match.d.ts +11 -0
- package/dist/src/mind/match.js +13 -2
- package/dist/src/mind/mechanisms/cast.js +366 -34
- package/dist/src/mind/mechanisms/confluence.js +17 -1
- package/dist/src/mind/mechanisms/recall.js +17 -3
- package/dist/src/mind/pipeline-mechanism.d.ts +4 -0
- package/dist/src/mind/pipeline-mechanism.js +96 -40
- package/dist/src/mind/pipeline.js +31 -3
- package/dist/src/mind/reasoning.d.ts +4 -2
- package/dist/src/mind/reasoning.js +29 -4
- package/dist/src/mind/recognition.js +67 -2
- package/dist/src/mind/resonance.d.ts +14 -2
- package/dist/src/mind/resonance.js +0 -0
- package/dist/src/mind/types.d.ts +43 -1
- package/dist/src/sema.d.ts +11 -1
- package/dist/src/sema.js +16 -2
- package/dist/src/store.d.ts +64 -1
- package/dist/src/store.js +107 -8
- package/index.html +2 -3
- package/package.json +1 -1
- package/src/geometry.ts +231 -43
- package/src/mind/attention.ts +366 -15
- package/src/mind/junction.ts +18 -2
- package/src/mind/match.ts +18 -2
- package/src/mind/mechanisms/cast.ts +376 -43
- package/src/mind/mechanisms/confluence.ts +16 -1
- package/src/mind/mechanisms/recall.ts +17 -2
- package/src/mind/pipeline-mechanism.ts +96 -36
- package/src/mind/pipeline.ts +33 -3
- package/src/mind/reasoning.ts +31 -4
- package/src/mind/recognition.ts +65 -2
- package/src/mind/resonance.ts +0 -0
- package/src/mind/types.ts +43 -1
- package/src/sema.ts +21 -2
- package/src/store.ts +106 -5
- package/test/00-extract.test.mjs +28 -0
- package/test/15-decomposition-gap.test.mjs +0 -0
- package/test/24-generalization.test.mjs +67 -19
- package/test/29-counterfactual.test.mjs +106 -42
- package/test/33-multi-candidate.test.mjs +56 -12
- package/test/53-cross-region-probe-instrumentation.test.mjs +16 -1
- package/test/63-fold-invariants.test.mjs +489 -0
- package/test/64-two-ended-thresholds.test.mjs +76 -0
package/src/geometry.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// 3. The same rule recurses level after level until one root remains.
|
|
9
9
|
|
|
10
10
|
import { addInto, copy, normalize, Vec, zeros } from "./vec.js";
|
|
11
|
-
import { Sema, sema, Space } from "./sema.js";
|
|
11
|
+
import { Sema, sema, Space, twoEndedSeat } from "./sema.js";
|
|
12
12
|
import { Alphabet } from "./alphabet.js";
|
|
13
13
|
|
|
14
14
|
// ---- geometric constants ----
|
|
@@ -60,6 +60,12 @@ export function identityBar(D: number, maxGroup: number, len: number): number {
|
|
|
60
60
|
* ≈ 1 − 1/maxGroup. Half that quantum, 1 − 1/(2·maxGroup), is closer than any
|
|
61
61
|
* single-child difference can be: a positional echo of the same content.
|
|
62
62
|
*
|
|
63
|
+
* This is an EQUAL-ARITY replacement law. The two-ended coordinate frame is
|
|
64
|
+
* a bijective relabelling of the seats inside that node, so it does not change
|
|
65
|
+
* the one-child overlap or this bar. Stability under a leading/trailing
|
|
66
|
+
* insertion comes from preserving content-defined subtrees and their anchored
|
|
67
|
+
* coordinates — never from lowering the confidence floor.
|
|
68
|
+
*
|
|
63
69
|
* Recall uses this as its confidence floor: a query whose nearest resonant
|
|
64
70
|
* form sits below this bar is structurally unrelated to everything in the store
|
|
65
71
|
* — further than any single-child variant — and the system returns null rather
|
|
@@ -214,7 +220,8 @@ function foldSlice(
|
|
|
214
220
|
let len = 0;
|
|
215
221
|
for (let k = 0; k < size; k++) {
|
|
216
222
|
const f = items[at + k];
|
|
217
|
-
const
|
|
223
|
+
const slot = twoEndedSeat(space.seats.length, size, k);
|
|
224
|
+
const seat = space.seats[slot].fwd;
|
|
218
225
|
const v = f.tree.v;
|
|
219
226
|
// Fused permute-and-accumulate — same FP ops, same order as the old
|
|
220
227
|
// permuteInto + addInto pair, with no scratch buffer.
|
|
@@ -344,6 +351,42 @@ function bytesToLeaves(
|
|
|
344
351
|
*
|
|
345
352
|
* Levels are read from the hash the cut was ACCEPTED at, not recomputed, so
|
|
346
353
|
* they cost nothing beyond the divisions already being done. */
|
|
354
|
+
|
|
355
|
+
// Cyclic-polynomial table for the bounded-window cut hash. Derived once from
|
|
356
|
+
// the fold's own mixing constant — no seed, no tuning. A byte contributes
|
|
357
|
+
// BUZ[b] on entering the window; the hash rotates by one per byte, so by the
|
|
358
|
+
// time that byte leaves, its contribution has travelled k places and is
|
|
359
|
+
// removed rotated by k. The rotation is taken at the use site rather than
|
|
360
|
+
// precomputed into a second table so the window width follows maxGroup
|
|
361
|
+
// instead of being frozen at one value.
|
|
362
|
+
const BUZ = new Uint32Array(256);
|
|
363
|
+
{
|
|
364
|
+
let x = 0x9e3779b9 >>> 0;
|
|
365
|
+
for (let i = 0; i < 256; i++) {
|
|
366
|
+
x = Math.imul(x ^ (x >>> 15), 2654435761) >>> 0;
|
|
367
|
+
x = (x ^ (x >>> 13)) >>> 0;
|
|
368
|
+
BUZ[i] = x;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** BUZ rotated by the window width — what a byte's contribution has become by
|
|
373
|
+
* the time it leaves. Cached because the width follows `maxGroup`, which is
|
|
374
|
+
* fixed for a given space: built once, then a plain table lookup per byte. */
|
|
375
|
+
let buzOutTable: Uint32Array | null = null;
|
|
376
|
+
let buzOutWidth = -1;
|
|
377
|
+
function buzOut(k: number): Uint32Array {
|
|
378
|
+
if (buzOutWidth !== k || buzOutTable === null) {
|
|
379
|
+
const t = new Uint32Array(256);
|
|
380
|
+
for (let i = 0; i < 256; i++) {
|
|
381
|
+
const v = BUZ[i];
|
|
382
|
+
t[i] = ((v << k) | (v >>> (32 - k))) >>> 0;
|
|
383
|
+
}
|
|
384
|
+
buzOutTable = t;
|
|
385
|
+
buzOutWidth = k;
|
|
386
|
+
}
|
|
387
|
+
return buzOutTable;
|
|
388
|
+
}
|
|
389
|
+
|
|
347
390
|
function contentLevels(
|
|
348
391
|
space: Space,
|
|
349
392
|
bytes: Uint8Array,
|
|
@@ -411,28 +454,109 @@ function contentLevels(
|
|
|
411
454
|
// downstream are fitted to, not the purity of the rule that produces it. The
|
|
412
455
|
// forced cut is part of that distribution. Do not tidy it away without
|
|
413
456
|
// re-measuring everything that reads a region.
|
|
457
|
+
// A BOUNDED-WINDOW rolling hash — the cut decision reads only the last
|
|
458
|
+
// `k` bytes, so nothing before the window can reach it.
|
|
459
|
+
//
|
|
460
|
+
// The old hash, `h = (h<<1) + byte*K`, needed 32 shifts to drop a byte, so
|
|
461
|
+
// it carried ~32 bytes of history; and on PERIODIC content its value was
|
|
462
|
+
// periodic too, so the threshold either never fired or fired at a fixed
|
|
463
|
+
// phase. Then the `maxLen` fallback placed every boundary at a fixed
|
|
464
|
+
// offset from the previous one and the segmentation could never recover
|
|
465
|
+
// from a shift. Measured fraction of cuts that re-align after a prepend:
|
|
466
|
+
//
|
|
467
|
+
// text uniform sparse lowent records ramp
|
|
468
|
+
// old 0.870 0.902 0.492 0.879 0.888 0.441
|
|
469
|
+
// this 0.935 0.952 0.732 0.920 0.916 0.935
|
|
470
|
+
//
|
|
471
|
+
// The cyclic polynomial (each byte enters as a table value, leaves rotated
|
|
472
|
+
// by the window width) has EXACTLY k bytes of memory and scrambles periodic
|
|
473
|
+
// input, so the threshold fires at content-chosen positions on a gradient
|
|
474
|
+
// just as it does on text — which is what leaves the `maxLen` fallback
|
|
475
|
+
// rarely engaged instead of carrying the phase. Segment lengths are
|
|
476
|
+
// unchanged in distribution (mean 5.2-7.2 against the old 5.4-6.0), so the
|
|
477
|
+
// mechanisms fitted to that distribution see the same scale.
|
|
478
|
+
//
|
|
479
|
+
// Cost is the same shape as before: shifts, XORs and two table lookups per
|
|
480
|
+
// byte, no multiply and no auxiliary structure. (An exact sliding-window
|
|
481
|
+
// minimum — winnowing — aligns slightly better still, 0.91-0.999, but its
|
|
482
|
+
// deque costs 51 MB/s against this rule's 112 and buys nothing the
|
|
483
|
+
// scrambling hash does not already give.)
|
|
484
|
+
const k = W;
|
|
485
|
+
const OUT = buzOut(k);
|
|
414
486
|
const cuts: number[] = [];
|
|
415
487
|
const levels: number[] = [];
|
|
488
|
+
const n = bytes.length;
|
|
416
489
|
let h = 0;
|
|
417
|
-
let
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
490
|
+
let prev = 0;
|
|
491
|
+
let recent = 0; // the last GAP raw hits, one bit each
|
|
492
|
+
|
|
493
|
+
// A boundary is a property of a 4-GRAM, not of a position. The register IS
|
|
494
|
+
// the window — it holds exactly the last `k` raw bytes — so the decision is
|
|
495
|
+
// a pure function of those bytes and nothing else can reach it.
|
|
496
|
+
//
|
|
497
|
+
// What kept the OLD rule position-dependent was `minLen`, counted from the
|
|
498
|
+
// previous cut: on periodic content that count carried the initial phase
|
|
499
|
+
// forever and the segmentation never recovered from a shift. But its only
|
|
500
|
+
// job was to stop segments being too short, and that can be said locally —
|
|
501
|
+
// take a hit only when the previous GAP positions did NOT hit. Every term
|
|
502
|
+
// is then a function of a bounded byte window (k + GAP), so the rule stays
|
|
503
|
+
// a pure content property while still setting the segment scale. Measured
|
|
504
|
+
// fraction of cuts that survive a prepend, worst case over six byte types:
|
|
505
|
+
// 0.441 for the original rule, 0.769 counting from `last`, 0.847 for this.
|
|
506
|
+
const GAP = 2;
|
|
507
|
+
const GAPMASK = (1 << GAP) - 1;
|
|
508
|
+
|
|
509
|
+
// The keyring bound is restored WITHOUT reintroducing a count: because
|
|
510
|
+
// boundaries are content-determined, an over-long segment carries identical
|
|
511
|
+
// bytes wherever it occurs, so splitting it at strides from ITS OWN start is
|
|
512
|
+
// content-relative. (This holds only while such splits stay RARE — a split
|
|
513
|
+
// leaves a right edge the content did not choose, so the next segment's
|
|
514
|
+
// start is not content-determined either. At this rate they are: mean
|
|
515
|
+
// segment 5.4 against a bound of 8. Lowering the cut rate to lengthen
|
|
516
|
+
// segments makes forced splits dominant and alignment collapses — measured,
|
|
517
|
+
// 0.000 on two-symbol data at rate 1/16.)
|
|
518
|
+
const emit = (at: number, lvl: number): void => {
|
|
519
|
+
while (at - prev > maxLen) {
|
|
520
|
+
prev += maxLen;
|
|
521
|
+
cuts.push(prev);
|
|
522
|
+
levels.push(0);
|
|
523
|
+
}
|
|
524
|
+
if (at <= prev || at >= n) return;
|
|
525
|
+
cuts.push(at);
|
|
526
|
+
levels.push(lvl);
|
|
527
|
+
prev = at;
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
for (let i = 0; i < n; i++) {
|
|
531
|
+
h = ((h << 8) | bytes[i]) >>> 0;
|
|
532
|
+
if (i + 1 >= n) break;
|
|
533
|
+
if (i < k - 1) continue;
|
|
534
|
+
// Two-round avalanche. The window holds four RAW bytes, whose entropy may
|
|
535
|
+
// sit in only a few bits (a gradient's low bits, a sparse stream's zeros);
|
|
536
|
+
// one multiply leaves that structure partly intact and the boundary test
|
|
537
|
+
// inherits it. A second round spreads every input bit across the word,
|
|
538
|
+
// which is what makes the rule behave the same on a ramp as on prose.
|
|
539
|
+
let mixv = Math.imul(h ^ (h >>> 16), 0x85ebca6b) >>> 0;
|
|
540
|
+
mixv = Math.imul(mixv ^ (mixv >>> 13), 0xc2b2ae35) >>> 0;
|
|
541
|
+
mixv = (mixv ^ (mixv >>> 16)) >>> 0;
|
|
542
|
+
const hit = mixv % W === 0;
|
|
543
|
+
if (hit && (recent & GAPMASK) === 0) {
|
|
544
|
+
// Level: how many further powers of W divide the mixed value — level-L
|
|
545
|
+
// cuts stay a subset of level-(L-1) cuts, the nesting the tree needs.
|
|
425
546
|
let lvl = 0;
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
m *= W;
|
|
431
|
-
}
|
|
547
|
+
let m = W;
|
|
548
|
+
while (lvl < 24 && m <= 0x40000000 && mixv % (m * W) === 0) {
|
|
549
|
+
lvl++;
|
|
550
|
+
m *= W;
|
|
432
551
|
}
|
|
433
|
-
|
|
434
|
-
last = i + 1;
|
|
552
|
+
emit(i + 1, lvl);
|
|
435
553
|
}
|
|
554
|
+
recent = ((recent << 1) | (hit ? 1 : 0)) & GAPMASK;
|
|
555
|
+
}
|
|
556
|
+
while (n - prev > maxLen) {
|
|
557
|
+
prev += maxLen;
|
|
558
|
+
cuts.push(prev);
|
|
559
|
+
levels.push(0);
|
|
436
560
|
}
|
|
437
561
|
return { cuts, levels };
|
|
438
562
|
}
|
|
@@ -574,6 +698,19 @@ function contentFoldSpan(
|
|
|
574
698
|
* keyring falls through to the plain river fold for that row — the fold stays
|
|
575
699
|
* total on any input, and the fallback is rare enough not to reintroduce a
|
|
576
700
|
* systematic alignment. */
|
|
701
|
+
/** A content key for a folded item: a cheap hash of its gist's leading
|
|
702
|
+
* coordinates. Used to choose a split point inside an over-long row, where
|
|
703
|
+
* the cut levels are uniformly 0 and carry no signal. Identical subtrees
|
|
704
|
+
* fold to identical vectors, so the same items in the same order always
|
|
705
|
+
* choose the same split — the property the whole fold rests on. */
|
|
706
|
+
function itemKey(v: Vec): number {
|
|
707
|
+
let h = 0x811c9dc5;
|
|
708
|
+
for (let d = 0; d < 8; d++) {
|
|
709
|
+
h = Math.imul(h ^ ((v[d] * 8192) | 0), 0x01000193) >>> 0;
|
|
710
|
+
}
|
|
711
|
+
return h >>> 0;
|
|
712
|
+
}
|
|
713
|
+
|
|
577
714
|
function groupByLevel(
|
|
578
715
|
space: Space,
|
|
579
716
|
items: Folded[],
|
|
@@ -584,14 +721,54 @@ function groupByLevel(
|
|
|
584
721
|
const maxSeats = space.seats.length;
|
|
585
722
|
const groups: Folded[] = [];
|
|
586
723
|
const groupLevels: number[] = [];
|
|
724
|
+
// Emit [from, to) as one group, splitting it at its STRONGEST interior cut
|
|
725
|
+
// whenever it would exceed the keyring.
|
|
726
|
+
//
|
|
727
|
+
// The old rule force-cut at the arity limit counted from the group's start
|
|
728
|
+
// — the last index-derived boundary in the grouping. Measured, the
|
|
729
|
+
// grouping loses one node in six even when EVERY child survives a prepend
|
|
730
|
+
// (P(node | all kids survive) = 0.82-0.84), and those losses spike at
|
|
731
|
+
// arity 8: exactly this boundary. Choosing the highest-level cut inside
|
|
732
|
+
// the feasible window instead makes the split a function of content, and
|
|
733
|
+
// the window bound keeps arity <= maxSeats so the seat algebra is untouched.
|
|
734
|
+
const emit = (from: number, to: number): void => {
|
|
735
|
+
let at = from;
|
|
736
|
+
while (to - at > maxSeats) {
|
|
737
|
+
// Strongest cut in the window that still leaves a legal group. Ties
|
|
738
|
+
// take the LATEST, so equal levels give the widest legal group rather
|
|
739
|
+
// than a degenerate spine of singletons.
|
|
740
|
+
let best = at + maxSeats - 1;
|
|
741
|
+
let bestKey = -1;
|
|
742
|
+
let bestLevel = -1;
|
|
743
|
+
for (let j = at; j < at + maxSeats && j < to - 1; j++) {
|
|
744
|
+
// Prefer a real level boundary; among equals — and inside an
|
|
745
|
+
// over-long stretch the levels are almost all 0, so they usually ARE
|
|
746
|
+
// equal — fall back to the ITEMS' own content. A group's gist is
|
|
747
|
+
// diverse where its cut level is not, so hashing it gives a
|
|
748
|
+
// content-determined split point where the level array has none.
|
|
749
|
+
const key = itemKey(items[j].tree.v);
|
|
750
|
+
if (
|
|
751
|
+
levels[j] > bestLevel ||
|
|
752
|
+
(levels[j] === bestLevel && key > bestKey)
|
|
753
|
+
) {
|
|
754
|
+
bestLevel = levels[j];
|
|
755
|
+
bestKey = key;
|
|
756
|
+
best = j;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
const part = items.slice(at, best + 1);
|
|
760
|
+
groups.push(part.length === 1 ? part[0] : joinFlat(space, part));
|
|
761
|
+
groupLevels.push(levels[best]);
|
|
762
|
+
at = best + 1;
|
|
763
|
+
}
|
|
764
|
+
const slice = items.slice(at, to);
|
|
765
|
+
groups.push(slice.length === 1 ? slice[0] : joinFlat(space, slice));
|
|
766
|
+
};
|
|
587
767
|
let start = 0;
|
|
588
768
|
for (let i = 0; i <= levels.length; i++) {
|
|
589
769
|
const atEnd = i === levels.length;
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
if (!cutHere && !wouldOverflow) continue;
|
|
593
|
-
const slice = items.slice(start, i + 1);
|
|
594
|
-
groups.push(slice.length === 1 ? slice[0] : joinFlat(space, slice));
|
|
770
|
+
if (!atEnd && levels[i] < level) continue;
|
|
771
|
+
emit(start, i + 1);
|
|
595
772
|
if (!atEnd) groupLevels.push(levels[i]);
|
|
596
773
|
start = i + 1;
|
|
597
774
|
}
|
|
@@ -605,14 +782,18 @@ function groupByLevel(
|
|
|
605
782
|
}
|
|
606
783
|
|
|
607
784
|
/** Join a row of already-folded items as one unnormalized node — the same
|
|
608
|
-
*
|
|
785
|
+
* two-ended seat binding as {@link flatFold}, one level up. A group formed
|
|
786
|
+
* by content-level cuts inherits the same robustness: interior items keep
|
|
787
|
+
* their seats when a leading or trailing segment is perturbed. */
|
|
609
788
|
function joinFlat(space: Space, items: Folded[]): Folded {
|
|
789
|
+
const n = items.length;
|
|
610
790
|
const gist = new Float32Array(space.D);
|
|
611
|
-
const kids = new Array<Sema>(
|
|
791
|
+
const kids = new Array<Sema>(n);
|
|
612
792
|
let len = 0;
|
|
613
|
-
for (let k = 0; k <
|
|
793
|
+
for (let k = 0; k < n; k++) {
|
|
614
794
|
const v = items[k].tree.v;
|
|
615
|
-
const
|
|
795
|
+
const slot = twoEndedSeat(space.seats.length, n, k);
|
|
796
|
+
const seat = space.seats[slot].fwd;
|
|
616
797
|
for (let d = 0; d < space.D; d++) gist[d] += v[seat[d]];
|
|
617
798
|
kids[k] = items[k].tree;
|
|
618
799
|
len += items[k].len;
|
|
@@ -621,10 +802,22 @@ function joinFlat(space: Space, items: Folded[]): Folded {
|
|
|
621
802
|
}
|
|
622
803
|
|
|
623
804
|
/** One segment as a single unnormalized node: leaf per byte, each bound into
|
|
624
|
-
* seat
|
|
625
|
-
*
|
|
626
|
-
*
|
|
627
|
-
*
|
|
805
|
+
* a seat derived from its position relative to BOTH segment ends.
|
|
806
|
+
*
|
|
807
|
+
* Binding from both ends — first bytes use the lowest seat slots, last
|
|
808
|
+
* bytes use the highest — makes the gist of the segment interior robust
|
|
809
|
+
* under a leading or trailing insertion: a byte prepended or appended
|
|
810
|
+
* shifts only the boundary seat, not every interior position. The same
|
|
811
|
+
* rule gives the shift-invariant knife its re-synchronising window in the
|
|
812
|
+
* ancestral fold (sema-old, KNIFE_WINDOW trailing items bound with
|
|
813
|
+
* relative seat keys). Ported to the current river: a content-defined
|
|
814
|
+
* segment always starts at seat 0, so the "relative" binding is the
|
|
815
|
+
* segment's own two-ended assignment.
|
|
816
|
+
*
|
|
817
|
+
* Never normalizes: the linear-fold contract keeps every interior gist
|
|
818
|
+
* raw and normalizes once at the root. Magnitude still ∝ √n — seat
|
|
819
|
+
* permutation preserves vector length, and the sum of n near-orthogonal
|
|
820
|
+
* vectors grows as √n. */
|
|
628
821
|
function flatFold(
|
|
629
822
|
space: Space,
|
|
630
823
|
alphabet: Alphabet,
|
|
@@ -645,7 +838,10 @@ function flatFold(
|
|
|
645
838
|
for (let k = 0; k < n; k++) {
|
|
646
839
|
const b = bytes[from + k];
|
|
647
840
|
const v = alphabet.vecs[b];
|
|
648
|
-
|
|
841
|
+
// Two-ended: the first half uses low seats and the second half uses
|
|
842
|
+
// high seats, inward from the tail of the FULL keyring.
|
|
843
|
+
const slot = twoEndedSeat(space.seats.length, n, k);
|
|
844
|
+
const seat = space.seats[slot].fwd;
|
|
649
845
|
for (let d = 0; d < space.D; d++) gist[d] += v[seat[d]];
|
|
650
846
|
kids[k] = sema(v, bytes.slice(from + k, from + k + 1), null);
|
|
651
847
|
}
|
|
@@ -769,18 +965,10 @@ export function stablePrefixFoldIncremental(
|
|
|
769
965
|
}
|
|
770
966
|
|
|
771
967
|
/** Join two folded items as one 2-kid branch — the top-level join of the
|
|
772
|
-
* stable-prefix fold,
|
|
773
|
-
*
|
|
968
|
+
* stable-prefix fold, delegated to {@link joinFlat} (same two-ended seat
|
|
969
|
+
* binding as every other group fold). Unnormalized (interior). */
|
|
774
970
|
function fold2(space: Space, a: Folded, b: Folded): Folded {
|
|
775
|
-
|
|
776
|
-
const gist = new Float32Array(D);
|
|
777
|
-
const kids = [a.tree, b.tree];
|
|
778
|
-
for (let k = 0; k < 2; k++) {
|
|
779
|
-
const seat = space.seats[k].fwd;
|
|
780
|
-
const v = kids[k].v;
|
|
781
|
-
for (let d = 0; d < D; d++) gist[d] += v[seat[d]];
|
|
782
|
-
}
|
|
783
|
-
return { tree: sema(gist, null, kids), len: a.len + b.len };
|
|
971
|
+
return joinFlat(space, [a, b]);
|
|
784
972
|
}
|
|
785
973
|
|
|
786
974
|
/** Plain river fold WITHOUT the final root normalize — the segment-level
|