@tangle-network/agent-eval 0.196.0 → 0.198.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.
- package/CHANGELOG.md +63 -0
- package/dist/active-curriculum-B_Lc9kfO.js +61 -0
- package/dist/active-curriculum-B_Lc9kfO.js.map +1 -0
- package/dist/adapters/http.d.ts +2 -2
- package/dist/analyst/index.d.ts +5 -5
- package/dist/analyst/index.js +3 -3
- package/dist/{analyze-runs-B6gv7vAT.js → analyze-runs-DFbp2gB8.js} +5 -5
- package/dist/{analyze-runs-B6gv7vAT.js.map → analyze-runs-DFbp2gB8.js.map} +1 -1
- package/dist/{benchmark-command-Bw1he2JM.js → benchmark-command-D8E5TaPC.js} +6 -5
- package/dist/{benchmark-command-Bw1he2JM.js.map → benchmark-command-D8E5TaPC.js.map} +1 -1
- package/dist/benchmarks/index.d.ts +4 -3
- package/dist/benchmarks/index.d.ts.map +1 -1
- package/dist/benchmarks/index.js +3 -3
- package/dist/campaign/index.d.ts +5 -4
- package/dist/campaign/index.js +13 -11
- package/dist/{campaign-qe1WC3Sz.js → campaign-Dlg2iOzH.js} +13 -12
- package/dist/{campaign-qe1WC3Sz.js.map → campaign-Dlg2iOzH.js.map} +1 -1
- package/dist/{campaign-evidence-DJc60F9C.js → campaign-evidence-jL_U7Ikv.js} +2 -2
- package/dist/{campaign-evidence-DJc60F9C.js.map → campaign-evidence-jL_U7Ikv.js.map} +1 -1
- package/dist/cli.js +243 -13
- package/dist/cli.js.map +1 -1
- package/dist/contract/index.d.ts +7 -6
- package/dist/contract/index.js +9 -9
- package/dist/{default-registry-D2csUBBQ.d.ts → default-registry-oK9hHfX_.d.ts} +3 -3
- package/dist/{default-registry-D2csUBBQ.d.ts.map → default-registry-oK9hHfX_.d.ts.map} +1 -1
- package/dist/{define-agent-eval-DwAaPvvH.d.ts → define-agent-eval-By9PImpa.d.ts} +5 -4
- package/dist/define-agent-eval-By9PImpa.d.ts.map +1 -0
- package/dist/{define-agent-eval-h2qYe0xY.js → define-agent-eval-DvBgSzAI.js} +6 -6
- package/dist/{define-agent-eval-h2qYe0xY.js.map → define-agent-eval-DvBgSzAI.js.map} +1 -1
- package/dist/diagnosis/index.js +1 -1
- package/dist/{dspy-rlm-engine-k_QdEBZo.js → dspy-rlm-engine-B5T43BkZ.js} +2 -2
- package/dist/{dspy-rlm-engine-k_QdEBZo.js.map → dspy-rlm-engine-B5T43BkZ.js.map} +1 -1
- package/dist/{engine-De2hfuiQ.d.ts → engine-Dc996kAg.d.ts} +2 -2
- package/dist/{engine-De2hfuiQ.d.ts.map → engine-Dc996kAg.d.ts.map} +1 -1
- package/dist/{eval-campaign-CXT2WYUe.js → eval-campaign-GapmPHIA.js} +2 -2
- package/dist/{eval-campaign-CXT2WYUe.js.map → eval-campaign-GapmPHIA.js.map} +1 -1
- package/dist/evaluation.d.ts +1 -1
- package/dist/experiment/index.d.ts +1 -1
- package/dist/experiment/index.js +8 -6
- package/dist/experiment/index.js.map +1 -1
- package/dist/{external-optimizer-process-_YIECYJ7.js → external-optimizer-process-CEXcCKqd.js} +2 -2
- package/dist/{external-optimizer-process-_YIECYJ7.js.map → external-optimizer-process-CEXcCKqd.js.map} +1 -1
- package/dist/{external-optimizer-subprocess-BrghTvEp.js → external-optimizer-subprocess-laM1-UDV.js} +2 -61
- package/dist/external-optimizer-subprocess-laM1-UDV.js.map +1 -0
- package/dist/fuzz.d.ts.map +1 -1
- package/dist/fuzz.js +2 -3
- package/dist/fuzz.js.map +1 -1
- package/dist/hosted/index.js +1 -1
- package/dist/{index-CigojbNH.d.ts → index-BhoOCduB.d.ts} +38 -945
- package/dist/index-BhoOCduB.d.ts.map +1 -0
- package/dist/{index-CpCIXObe.d.ts → index-D-tI1vgf.d.ts} +6 -6
- package/dist/{index-CpCIXObe.d.ts.map → index-D-tI1vgf.d.ts.map} +1 -1
- package/dist/{index-kKECJfib.d.ts → index-lbwvO9vJ.d.ts} +3 -3
- package/dist/{index-kKECJfib.d.ts.map → index-lbwvO9vJ.d.ts.map} +1 -1
- package/dist/index.d.ts +9 -9
- package/dist/index.js +15 -13
- package/dist/index.js.map +1 -1
- package/dist/jev-protocol.d.ts +17 -9
- package/dist/jev-protocol.d.ts.map +1 -1
- package/dist/jev-protocol.js +22 -14
- package/dist/jev-protocol.js.map +1 -1
- package/dist/jev.d.ts +3 -3
- package/dist/jev.js +3 -7
- package/dist/jev.js.map +1 -1
- package/dist/{llm-judge-CxfoYqdk.js → llm-judge-BYWXsxhZ.js} +529 -2079
- package/dist/llm-judge-BYWXsxhZ.js.map +1 -0
- package/dist/{matrix-C1X-fIVX.d.ts → matrix-BnksRaDD.d.ts} +2 -2
- package/dist/{matrix-C1X-fIVX.d.ts.map → matrix-BnksRaDD.d.ts.map} +1 -1
- package/dist/multiplicity-D9cjmStq.js +101 -0
- package/dist/multiplicity-D9cjmStq.js.map +1 -0
- package/dist/multishot/golden/index.d.ts +1 -1
- package/dist/multishot/golden/index.js +1 -1
- package/dist/multishot/index.d.ts +2 -2
- package/dist/multishot/index.js +1 -1
- package/dist/openapi.json +1 -1
- package/dist/{paired-arms-JSDcs2zx.js → paired-arms-DKZFVPt8.js} +4 -398
- package/dist/paired-arms-DKZFVPt8.js.map +1 -0
- package/dist/paired-binary-fzkwENI4.js +399 -0
- package/dist/paired-binary-fzkwENI4.js.map +1 -0
- package/dist/{paired-promotion-decision-DTKl6-G8.js → paired-promotion-decision-D5B1Cxc2.js} +78 -5
- package/dist/paired-promotion-decision-D5B1Cxc2.js.map +1 -0
- package/dist/{power-and-mde-BJOpt6pq.js → power-and-mde-K_CmO3dc.js} +3 -101
- package/dist/power-and-mde-K_CmO3dc.js.map +1 -0
- package/dist/{produced-state-B0QMdiLh.js → produced-state-DOfmCEwP.js} +3 -3
- package/dist/{produced-state-B0QMdiLh.js.map → produced-state-DOfmCEwP.js.map} +1 -1
- package/dist/{promotion-policy-Cy5OV8BV.js → promotion-policy-C6FAJvmZ.js} +3 -3
- package/dist/{promotion-policy-Cy5OV8BV.js.map → promotion-policy-C6FAJvmZ.js.map} +1 -1
- package/dist/reporting.js +3 -3
- package/dist/rl.d.ts +17 -39
- package/dist/rl.d.ts.map +1 -1
- package/dist/rl.js +5 -5
- package/dist/search/index.d.ts +1162 -0
- package/dist/search/index.d.ts.map +1 -0
- package/dist/search/index.js +187 -0
- package/dist/search/index.js.map +1 -0
- package/dist/search-kernel-CuvbakC_.d.ts +1176 -0
- package/dist/search-kernel-CuvbakC_.d.ts.map +1 -0
- package/dist/search-kernel-D8_kxChy.js +2399 -0
- package/dist/search-kernel-D8_kxChy.js.map +1 -0
- package/dist/{search-ledger-9W0g5FP6.js → search-ledger-BnUytR8X.js} +3 -1
- package/dist/search-ledger-BnUytR8X.js.map +1 -0
- package/dist/{search-shipper-CYbmJQx8.js → search-shipper-Cn8cxcyp.js} +2 -2
- package/dist/{search-shipper-CYbmJQx8.js.map → search-shipper-Cn8cxcyp.js.map} +1 -1
- package/dist/search-summary-Dmppg8kV.js +133 -0
- package/dist/search-summary-Dmppg8kV.js.map +1 -0
- package/dist/{semantic-concept-judge-Bh5lvKGg.js → semantic-concept-judge-2SE_kXGF.js} +2 -2
- package/dist/{semantic-concept-judge-Bh5lvKGg.js.map → semantic-concept-judge-2SE_kXGF.js.map} +1 -1
- package/dist/{sequential-C21_PP7F.js → sequential-Yz3OSskl.js} +2 -2
- package/dist/{sequential-C21_PP7F.js.map → sequential-Yz3OSskl.js.map} +1 -1
- package/dist/single-run-lock-fVu3MZWH.js +62 -0
- package/dist/single-run-lock-fVu3MZWH.js.map +1 -0
- package/dist/{skillopt-optimization-method-IbEhlA1V.js → skillopt-optimization-method-BMP2qV_1.js} +46 -33
- package/dist/{skillopt-optimization-method-IbEhlA1V.js.map → skillopt-optimization-method-BMP2qV_1.js.map} +1 -1
- package/dist/{statistical-heldout-DANVmyug.js → statistical-heldout-BAFDCo07.js} +2 -2
- package/dist/{statistical-heldout-DANVmyug.js.map → statistical-heldout-BAFDCo07.js.map} +1 -1
- package/dist/{statistical-heldout-BedVSZb2.d.ts → statistical-heldout-CMpxMjlQ.d.ts} +2 -2
- package/dist/{statistical-heldout-BedVSZb2.d.ts.map → statistical-heldout-CMpxMjlQ.d.ts.map} +1 -1
- package/dist/{store-tool-spans-CawP-WxM.d.ts → store-tool-spans-CUwWSXoj.d.ts} +3 -3
- package/dist/{store-tool-spans-CawP-WxM.d.ts.map → store-tool-spans-CUwWSXoj.d.ts.map} +1 -1
- package/dist/{summary-report-BMvou2oZ.js → summary-report-BeYnvddy.js} +4 -3
- package/dist/{summary-report-BMvou2oZ.js.map → summary-report-BeYnvddy.js.map} +1 -1
- package/dist/{tool-groups-B4JVrH3g.d.ts → tool-groups-DVJAR1GE.d.ts} +2 -2
- package/dist/tool-groups-DVJAR1GE.d.ts.map +1 -0
- package/dist/trace-repair/index.d.ts +1 -1
- package/dist/traces.d.ts +2 -2
- package/dist/tree-DHHWsH-X.js +4045 -0
- package/dist/tree-DHHWsH-X.js.map +1 -0
- package/dist/{types-B3jzCp0p.js → types-B3jzCp0p2.js} +1 -1
- package/dist/types-B3jzCp0p2.js.map +1 -0
- package/dist/{types-CjFDKMDe.d.ts → types-D94EanDL.d.ts} +2 -2
- package/dist/{types-CjFDKMDe.d.ts.map → types-D94EanDL.d.ts.map} +1 -1
- package/dist/{types-B2673PBj.d.ts → types-EBUav9Bu.d.ts} +8 -4
- package/dist/types-EBUav9Bu.d.ts.map +1 -0
- package/docs/campaign-proposers.md +2 -0
- package/docs/design/statistics-decisions.md +1 -1
- package/docs/jev.md +3 -2
- package/docs/search-ledger.md +145 -8
- package/package.json +6 -1
- package/dist/active-curriculum-OjIWgrUJ.js +0 -204
- package/dist/active-curriculum-OjIWgrUJ.js.map +0 -1
- package/dist/define-agent-eval-DwAaPvvH.d.ts.map +0 -1
- package/dist/external-optimizer-subprocess-BrghTvEp.js.map +0 -1
- package/dist/index-CigojbNH.d.ts.map +0 -1
- package/dist/llm-judge-CxfoYqdk.js.map +0 -1
- package/dist/paired-arms-JSDcs2zx.js.map +0 -1
- package/dist/paired-delta-test-BF-FEP8V.js +0 -78
- package/dist/paired-delta-test-BF-FEP8V.js.map +0 -1
- package/dist/paired-promotion-decision-DTKl6-G8.js.map +0 -1
- package/dist/power-and-mde-BJOpt6pq.js.map +0 -1
- package/dist/search-ledger-9W0g5FP6.js.map +0 -1
- package/dist/search-summary-CcavvYLe.js +0 -378
- package/dist/search-summary-CcavvYLe.js.map +0 -1
- package/dist/tool-groups-B4JVrH3g.d.ts.map +0 -1
- package/dist/types-B2673PBj.d.ts.map +0 -1
- package/dist/types-B3jzCp0p.js.map +0 -1
|
@@ -0,0 +1,4045 @@
|
|
|
1
|
+
import { a as hashCanonical, i as compareCodeUnits, r as canonicalString } from "./canonical-BsZVgBhW.js";
|
|
2
|
+
import { s as zQuantile } from "./internal-BMFSR8Ns.js";
|
|
3
|
+
import { t as mulberry32 } from "./random-Dn5fPWkt.js";
|
|
4
|
+
import { r as holm } from "./multiplicity-D9cjmStq.js";
|
|
5
|
+
import { a as pairedSignTest, n as DECISION_PAIRED_DELTA_STATISTIC } from "./paired-tests-C8iCsioC.js";
|
|
6
|
+
import { i as pairedDeltaTest, r as minimumPairsForPairedDeltaTest } from "./paired-promotion-decision-D5B1Cxc2.js";
|
|
7
|
+
import { C as searchPosterior, E as paretoFrontier, _ as rankingSplit, a as searchPolicyView, b as estimateNode, g as INSUFFICIENT_FROM, u as verifySearchClaim, v as summarizeSamples, w as seedFromDigest } from "./search-kernel-D8_kxChy.js";
|
|
8
|
+
import { f as searchUnitScores } from "./search-ledger-BnUytR8X.js";
|
|
9
|
+
import { defineInlineResource } from "@tangle-network/agent-interface";
|
|
10
|
+
//#region src/search/lenses/edit-credit.ts
|
|
11
|
+
/**
|
|
12
|
+
* `editCredit`: the edits of a search as genes, followed down its lineage.
|
|
13
|
+
*
|
|
14
|
+
* Every lineage edge is a parent-to-child change of an artifact. The lens
|
|
15
|
+
* diffs the two artifacts line by line (whitespace-normalized) and cuts each
|
|
16
|
+
* changed run into paragraphs: an added paragraph is an `insert` gene, a
|
|
17
|
+
* removed paragraph a `delete` gene. A gene's id hashes its kind, its location
|
|
18
|
+
* in the artifact and its normalized lines, so the same edit has the same id
|
|
19
|
+
* wherever and whenever a proposer makes it. A node carries an insert gene
|
|
20
|
+
* when the paragraph is present at that location, and a delete gene when it is
|
|
21
|
+
* absent. Carrying is read from each node's content, so it follows merges,
|
|
22
|
+
* re-proposals and reverts without any bookkeeping in the record.
|
|
23
|
+
*
|
|
24
|
+
* A step is one lineage parent and its child. A gene flips on a step when the
|
|
25
|
+
* parent and the child disagree on carrying it. The gene's credit pairs, per
|
|
26
|
+
* step, the side that carries it against the side that lacks it on the units
|
|
27
|
+
* both scored, and averages those contrasts across its steps inside each unit.
|
|
28
|
+
* Only clean steps count: steps on which nothing flips except edits introduced
|
|
29
|
+
* together with the gene. A merge that also brings in the other parent's edits
|
|
30
|
+
* is therefore not evidence for any one of them, and genes that always move
|
|
31
|
+
* together are reported as linked, because their credits are one number.
|
|
32
|
+
*
|
|
33
|
+
* Interactions compare a gene's step contrasts where another gene is present
|
|
34
|
+
* against where it is absent, in both directions, and are flagged after a Holm
|
|
35
|
+
* correction for a factorial test rather than claimed.
|
|
36
|
+
*
|
|
37
|
+
* The lens reads node artifacts through `readArtifact`, which the caller backs
|
|
38
|
+
* with verified blob bytes. An artifact the caller cannot read, and a code
|
|
39
|
+
* surface whose patch bytes are not in the ledger, leave the node's content
|
|
40
|
+
* unknown: its steps yield no genes and no contrasts.
|
|
41
|
+
*/
|
|
42
|
+
const CONFIDENCE = .95;
|
|
43
|
+
const RESAMPLES = 2e3;
|
|
44
|
+
const DESCRIPTIVE_FROM = minimumPairsForPairedDeltaTest(CONFIDENCE);
|
|
45
|
+
const INTERACTION_ALPHA = .05;
|
|
46
|
+
const DEFAULT_INTERACTION_GENES = 24;
|
|
47
|
+
/** Signal name a policy reads. */
|
|
48
|
+
const EDIT_CREDIT_SIGNAL = "reusableHunks";
|
|
49
|
+
/** The estimator every gene credit names. */
|
|
50
|
+
const EDIT_CREDIT_ESTIMATOR = {
|
|
51
|
+
uri: "npm:@tangle-network/agent-eval/search#editCredit",
|
|
52
|
+
revision: hashCanonical({
|
|
53
|
+
name: "tangle.edit-credit.2026-09",
|
|
54
|
+
gene: "a paragraph of whitespace-normalized lines added (insert) or removed (delete) at one artifact location",
|
|
55
|
+
step: "one lineage parent and its child, both with known content and neither invalid",
|
|
56
|
+
clean: "the gene flips on the step and every gene that flips was introduced on one edge that introduced it",
|
|
57
|
+
unit: "mean of a node’s scored cells in the unit; per unit, carrier-side and lacking-side means averaged over the clean steps where both sides scored it",
|
|
58
|
+
statistic: DECISION_PAIRED_DELTA_STATISTIC,
|
|
59
|
+
interval: "percentile paired bootstrap",
|
|
60
|
+
confidence: CONFIDENCE,
|
|
61
|
+
resamples: RESAMPLES,
|
|
62
|
+
rng: "mulberry32",
|
|
63
|
+
seed: "the first 32 bits of cellSetDigest",
|
|
64
|
+
signTest: "exact, one-sided toward improvement in the objective direction",
|
|
65
|
+
methods: {
|
|
66
|
+
none: "below 2 units",
|
|
67
|
+
insufficient: `below ${DESCRIPTIVE_FROM} units`,
|
|
68
|
+
descriptive: `below 20 units`,
|
|
69
|
+
bootstrap: `from 20 units`
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* The edit-credit lens. Pure: the result depends only on `state` and what
|
|
75
|
+
* `readArtifact` returns for its references.
|
|
76
|
+
*/
|
|
77
|
+
function editCredit(state, options) {
|
|
78
|
+
const header = state.header;
|
|
79
|
+
if (!header) throw new Error(`editCredit: search ${state.searchId} has not been opened`);
|
|
80
|
+
const split = options.split ?? rankingSplit(state);
|
|
81
|
+
const direction = header.objective.direction;
|
|
82
|
+
const sign = direction === "maximize" ? 1 : -1;
|
|
83
|
+
const interactionGenes = options.interactionGenes ?? DEFAULT_INTERACTION_GENES;
|
|
84
|
+
if (!Number.isSafeInteger(interactionGenes) || interactionGenes < 0) throw new Error(`editCredit: interactionGenes must be a non-negative integer`);
|
|
85
|
+
const nodes = state.nodes();
|
|
86
|
+
const byId = new Map(nodes.map((node) => [node.nodeId, node]));
|
|
87
|
+
const contents = /* @__PURE__ */ new Map();
|
|
88
|
+
for (const node of nodes) contents.set(node.nodeId, nodeContent(options, node.artifact));
|
|
89
|
+
const known = (nodeId) => contents.get(nodeId);
|
|
90
|
+
const invalid = new Set(nodes.filter((node) => node.status === "invalid").map((n) => n.nodeId));
|
|
91
|
+
const genes = /* @__PURE__ */ new Map();
|
|
92
|
+
const byLocus = /* @__PURE__ */ new Map();
|
|
93
|
+
const introducedOn = /* @__PURE__ */ new Map();
|
|
94
|
+
const unknownEdges = {};
|
|
95
|
+
const stepList = [];
|
|
96
|
+
const stepKeys = /* @__PURE__ */ new Set();
|
|
97
|
+
let lineageEdges = 0;
|
|
98
|
+
let readEdges = 0;
|
|
99
|
+
let edgeOrdinal = 0;
|
|
100
|
+
for (const edge of state.edges()) {
|
|
101
|
+
edgeOrdinal += 1;
|
|
102
|
+
const child = byId.get(edge.childNodeId);
|
|
103
|
+
const parents = lineageParents(state, edge, child, byId);
|
|
104
|
+
if (parents.length === 0) continue;
|
|
105
|
+
lineageEdges += 1;
|
|
106
|
+
const missing = [child.nodeId, ...parents].map((nodeId) => contents.get(nodeId)).find((content) => !content.known);
|
|
107
|
+
if (missing && !missing.known) {
|
|
108
|
+
unknownEdges[missing.reason] = (unknownEdges[missing.reason] ?? 0) + 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
readEdges += 1;
|
|
112
|
+
const childContent = known(child.nodeId);
|
|
113
|
+
const reproposal = child.edgeIds[0] !== edge.edgeId;
|
|
114
|
+
const wholeLineage = edge.parents.every((parent) => parent.searchId === state.searchId && parents.includes(parent.nodeId));
|
|
115
|
+
const cuts = /* @__PURE__ */ new Map();
|
|
116
|
+
for (const parentId of parents) {
|
|
117
|
+
const key = `${parentId}>${child.nodeId}`;
|
|
118
|
+
if (!stepKeys.has(key)) {
|
|
119
|
+
stepKeys.add(key);
|
|
120
|
+
stepList.push({
|
|
121
|
+
key,
|
|
122
|
+
parentId,
|
|
123
|
+
childId: child.nodeId,
|
|
124
|
+
flips: /* @__PURE__ */ new Set()
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
for (const cut of edgeCuts(known(parentId), childContent)) {
|
|
128
|
+
const locus = locusOf(cut);
|
|
129
|
+
if (!cuts.has(locus)) cuts.set(locus, cut);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (!wholeLineage) continue;
|
|
133
|
+
const introducedHere = [];
|
|
134
|
+
for (const [locus, cut] of cuts) {
|
|
135
|
+
const existing = byLocus.get(locus);
|
|
136
|
+
const gene = existing ?? cut;
|
|
137
|
+
const childCarries = carries(childContent, gene);
|
|
138
|
+
const parentCarries = parents.filter((parentId) => carries(known(parentId), gene));
|
|
139
|
+
if (existing && !childCarries && parentCarries.length > 0) {
|
|
140
|
+
existing.dropped += 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (!childCarries || parentCarries.length > 0) continue;
|
|
144
|
+
let record = existing;
|
|
145
|
+
if (!record) {
|
|
146
|
+
const geneId = `gene_${hashCanonical({
|
|
147
|
+
kind: cut.kind,
|
|
148
|
+
path: cut.path,
|
|
149
|
+
lines: cut.lines
|
|
150
|
+
}).slice(7).slice(0, 24)}`;
|
|
151
|
+
record = {
|
|
152
|
+
...cut,
|
|
153
|
+
geneId,
|
|
154
|
+
locus,
|
|
155
|
+
introduced: [],
|
|
156
|
+
dropped: 0,
|
|
157
|
+
order: [edgeOrdinal, cut.position]
|
|
158
|
+
};
|
|
159
|
+
genes.set(geneId, record);
|
|
160
|
+
byLocus.set(locus, record);
|
|
161
|
+
}
|
|
162
|
+
record.introduced.push({
|
|
163
|
+
nodeId: child.nodeId,
|
|
164
|
+
edgeId: edge.edgeId,
|
|
165
|
+
operator: edge.operator,
|
|
166
|
+
parents,
|
|
167
|
+
reproposal
|
|
168
|
+
});
|
|
169
|
+
introducedHere.push(record.geneId);
|
|
170
|
+
}
|
|
171
|
+
introducedOn.set(edge.edgeId, introducedHere);
|
|
172
|
+
}
|
|
173
|
+
const carriersOf = /* @__PURE__ */ new Map();
|
|
174
|
+
const carried = /* @__PURE__ */ new Map();
|
|
175
|
+
for (const record of genes.values()) {
|
|
176
|
+
const set = /* @__PURE__ */ new Set();
|
|
177
|
+
for (const node of nodes) {
|
|
178
|
+
const content = contents.get(node.nodeId);
|
|
179
|
+
if (content.known && carries(content, record)) {
|
|
180
|
+
set.add(node.nodeId);
|
|
181
|
+
appendTo(carried, node.nodeId, record.geneId);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
carriersOf.set(record.geneId, set);
|
|
185
|
+
}
|
|
186
|
+
for (const step of stepList) for (const [geneId, set] of carriersOf) if (set.has(step.parentId) !== set.has(step.childId)) step.flips.add(geneId);
|
|
187
|
+
const measurable = stepList.filter((step) => !invalid.has(step.parentId) && !invalid.has(step.childId));
|
|
188
|
+
const editsOf = /* @__PURE__ */ new Map();
|
|
189
|
+
for (const record of genes.values()) editsOf.set(record.geneId, record.introduced.map((introduction) => new Set(introducedOn.get(introduction.edgeId))));
|
|
190
|
+
const unitMeansOf = memo((nodeId) => {
|
|
191
|
+
const means = /* @__PURE__ */ new Map();
|
|
192
|
+
for (const unit of state.unitScores(nodeId, split)) means.set(unit.unitId, unit.mean);
|
|
193
|
+
return means;
|
|
194
|
+
});
|
|
195
|
+
const cellsDigestOf = memo((nodeId) => state.scoredCells(nodeId, split).map((cell) => [
|
|
196
|
+
cell.cellId,
|
|
197
|
+
cell.unitId,
|
|
198
|
+
cell.attempt,
|
|
199
|
+
cell.score
|
|
200
|
+
]).sort((left, right) => compareCodeUnits(left[0], right[0])));
|
|
201
|
+
const cleanOf = /* @__PURE__ */ new Map();
|
|
202
|
+
const flipCount = /* @__PURE__ */ new Map();
|
|
203
|
+
for (const step of measurable) for (const geneId of step.flips) {
|
|
204
|
+
flipCount.set(geneId, (flipCount.get(geneId) ?? 0) + 1);
|
|
205
|
+
if (!editsOf.get(geneId).some((edit) => [...step.flips].every((other) => edit.has(other)))) continue;
|
|
206
|
+
const childCarries = carriersOf.get(geneId).has(step.childId);
|
|
207
|
+
appendTo(cleanOf, geneId, {
|
|
208
|
+
step,
|
|
209
|
+
carrierId: childCarries ? step.childId : step.parentId,
|
|
210
|
+
lackingId: childCarries ? step.parentId : step.childId
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
const credits = /* @__PURE__ */ new Map();
|
|
214
|
+
for (const record of genes.values()) {
|
|
215
|
+
const steps = cleanOf.get(record.geneId) ?? [];
|
|
216
|
+
const perUnit = /* @__PURE__ */ new Map();
|
|
217
|
+
for (const { carrierId, lackingId } of steps) {
|
|
218
|
+
const lacking = unitMeansOf(lackingId);
|
|
219
|
+
for (const [unitId, carrierMean] of unitMeansOf(carrierId)) {
|
|
220
|
+
const lackingMean = lacking.get(unitId);
|
|
221
|
+
if (lackingMean === void 0) continue;
|
|
222
|
+
const entry = perUnit.get(unitId) ?? {
|
|
223
|
+
carrier: 0,
|
|
224
|
+
lacking: 0,
|
|
225
|
+
count: 0
|
|
226
|
+
};
|
|
227
|
+
entry.carrier += carrierMean;
|
|
228
|
+
entry.lacking += lackingMean;
|
|
229
|
+
entry.count += 1;
|
|
230
|
+
perUnit.set(unitId, entry);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const unitIds = [...perUnit.keys()].sort(compareCodeUnits);
|
|
234
|
+
const cellSetDigest = hashCanonical({
|
|
235
|
+
split,
|
|
236
|
+
steps: steps.map(({ carrierId, lackingId }) => [
|
|
237
|
+
carrierId,
|
|
238
|
+
lackingId,
|
|
239
|
+
cellsDigestOf(carrierId),
|
|
240
|
+
cellsDigestOf(lackingId)
|
|
241
|
+
]).sort((left, right) => compareCodeUnits(canonicalString(left), canonicalString(right)))
|
|
242
|
+
});
|
|
243
|
+
const carrierUnits = /* @__PURE__ */ new Set();
|
|
244
|
+
for (const { carrierId } of steps) for (const unitId of unitMeansOf(carrierId).keys()) carrierUnits.add(unitId);
|
|
245
|
+
credits.set(record.geneId, pairedEstimate({
|
|
246
|
+
against: `${record.geneId}:lacking`,
|
|
247
|
+
split,
|
|
248
|
+
direction,
|
|
249
|
+
units: carrierUnits.size,
|
|
250
|
+
lacking: unitIds.map((unitId) => perUnit.get(unitId).lacking / perUnit.get(unitId).count),
|
|
251
|
+
carrier: unitIds.map((unitId) => perUnit.get(unitId).carrier / perUnit.get(unitId).count),
|
|
252
|
+
cellSetDigest
|
|
253
|
+
}));
|
|
254
|
+
}
|
|
255
|
+
const linkageKey = (geneId) => canonicalString((cleanOf.get(geneId) ?? []).map(({ step, carrierId }) => `${step.key}:${carrierId === step.childId ? "+" : "-"}`).sort(compareCodeUnits));
|
|
256
|
+
const linkGroups = /* @__PURE__ */ new Map();
|
|
257
|
+
for (const record of [...genes.values()].sort((a, b) => compareOrder(a.order, b.order))) {
|
|
258
|
+
if (!cleanOf.has(record.geneId)) continue;
|
|
259
|
+
appendTo(linkGroups, linkageKey(record.geneId), record.geneId);
|
|
260
|
+
}
|
|
261
|
+
const geneList = [...genes.values()].map((record) => {
|
|
262
|
+
const clean = cleanOf.get(record.geneId) ?? [];
|
|
263
|
+
const group = clean.length > 0 ? linkGroups.get(linkageKey(record.geneId)) : [];
|
|
264
|
+
const carriers = carriersOf.get(record.geneId);
|
|
265
|
+
const { credit, verdict } = credits.get(record.geneId);
|
|
266
|
+
return {
|
|
267
|
+
geneId: record.geneId,
|
|
268
|
+
kind: record.kind,
|
|
269
|
+
path: record.path,
|
|
270
|
+
lines: record.lines,
|
|
271
|
+
text: record.text,
|
|
272
|
+
introduced: record.introduced,
|
|
273
|
+
dropped: record.dropped,
|
|
274
|
+
edit: [...editsOf.get(record.geneId)[0]].sort((a, b) => compareOrder(genes.get(a).order, genes.get(b).order)),
|
|
275
|
+
carriers: [...carriers].filter((nodeId) => !invalid.has(nodeId)).length,
|
|
276
|
+
invalidCarriers: [...carriers].filter((nodeId) => invalid.has(nodeId)).length,
|
|
277
|
+
steps: {
|
|
278
|
+
flips: flipCount.get(record.geneId) ?? 0,
|
|
279
|
+
clean: clean.length,
|
|
280
|
+
gained: clean.filter(({ step, carrierId }) => carrierId === step.childId).length,
|
|
281
|
+
lost: clean.filter(({ step, carrierId }) => carrierId === step.parentId).length
|
|
282
|
+
},
|
|
283
|
+
credit,
|
|
284
|
+
verdict,
|
|
285
|
+
linkage: group.length > 1 ? group[0] : null
|
|
286
|
+
};
|
|
287
|
+
});
|
|
288
|
+
const verdictRank = {
|
|
289
|
+
reusable: 0,
|
|
290
|
+
harmful: 1,
|
|
291
|
+
unresolved: 2,
|
|
292
|
+
insufficient: 3
|
|
293
|
+
};
|
|
294
|
+
geneList.sort((a, b) => verdictRank[a.verdict] - verdictRank[b.verdict] || improvementLow(b.credit, sign) - improvementLow(a.credit, sign) || gain(b.credit, sign) - gain(a.credit, sign) || compareOrder(genes.get(a.geneId).order, genes.get(b.geneId).order));
|
|
295
|
+
const counts = emptyCounts();
|
|
296
|
+
const editCounts = emptyCounts();
|
|
297
|
+
for (const gene of geneList) {
|
|
298
|
+
counts[gene.verdict] += 1;
|
|
299
|
+
if ((gene.linkage ?? (gene.steps.clean === 0 ? gene.edit[0] : gene.geneId)) === gene.geneId) editCounts[gene.verdict] += 1;
|
|
300
|
+
}
|
|
301
|
+
const interactions = interactionPairs({
|
|
302
|
+
genes: geneList,
|
|
303
|
+
cleanOf,
|
|
304
|
+
carriersOf,
|
|
305
|
+
unitMeansOf,
|
|
306
|
+
limit: interactionGenes,
|
|
307
|
+
sign
|
|
308
|
+
});
|
|
309
|
+
const skillCandidates = skills(geneList, genes, header.subject, split);
|
|
310
|
+
const lineage = lineageRows(state, nodes, byId, contents, carried);
|
|
311
|
+
const isMeasured = (gene) => gene.credit.method === "descriptive" || gene.credit.method === "bootstrap";
|
|
312
|
+
const measured = geneList.filter(isMeasured).length;
|
|
313
|
+
const measuredLeads = geneList.filter((gene) => isMeasured(gene) && (gene.linkage ?? gene.geneId) === gene.geneId);
|
|
314
|
+
const expectedReusable = plain(Math.round(measuredLeads.reduce((sum, gene) => sum + chanceReusable(gene.credit.pairs), 0) * 1e6) / 1e6);
|
|
315
|
+
const top = geneList.filter((gene) => gene.verdict === "reusable" && (gene.linkage ?? gene.geneId) === gene.geneId).map((gene) => gene.geneId);
|
|
316
|
+
return {
|
|
317
|
+
signal: measured === 0 ? {
|
|
318
|
+
name: EDIT_CREDIT_SIGNAL,
|
|
319
|
+
value: null,
|
|
320
|
+
basis: `insufficient: no gene has ${DESCRIPTIVE_FROM} ${split} units on clean steps (${geneList.length} genes, ${measurable.length} measurable steps)`,
|
|
321
|
+
top: []
|
|
322
|
+
} : {
|
|
323
|
+
name: EDIT_CREDIT_SIGNAL,
|
|
324
|
+
value: editCounts.reusable,
|
|
325
|
+
basis: `independent edits pairedDeltaTest finds improve the objective on the ${split} split (${counts.reusable} genes), of ${measuredLeads.length} edits (${measured} genes) measured on ${DESCRIPTIVE_FROM} or more units; about ${expectedReusable.toFixed(2)} would be called reusable by chance with no true effect (unadjusted)`,
|
|
326
|
+
top
|
|
327
|
+
},
|
|
328
|
+
data: {
|
|
329
|
+
split,
|
|
330
|
+
direction,
|
|
331
|
+
edges: {
|
|
332
|
+
lineage: lineageEdges,
|
|
333
|
+
read: readEdges,
|
|
334
|
+
unknown: unknownEdges
|
|
335
|
+
},
|
|
336
|
+
steps: {
|
|
337
|
+
total: stepList.length,
|
|
338
|
+
invalidEnd: stepList.length - measurable.length
|
|
339
|
+
},
|
|
340
|
+
nodes: {
|
|
341
|
+
total: nodes.length,
|
|
342
|
+
contentUnknown: [...contents.values()].filter((content) => !content.known).length,
|
|
343
|
+
invalid: invalid.size
|
|
344
|
+
},
|
|
345
|
+
genes: geneList,
|
|
346
|
+
counts,
|
|
347
|
+
editCounts,
|
|
348
|
+
interactions,
|
|
349
|
+
skillCandidates,
|
|
350
|
+
chance: {
|
|
351
|
+
measuredEdits: measuredLeads.length,
|
|
352
|
+
expectedReusable,
|
|
353
|
+
method: `per measured edit, the chance with no true effect that its verdict is reusable: the exact one-sided sign test's attained size at its unit count below 20 units (at most ${(1 - CONFIDENCE) / 2}; the point-estimate condition only lowers it), the nominal ${(1 - CONFIDENCE) / 2} of the percentile bootstrap from 20; summed, so it is the expected number of false reusable edits if every edit had no effect`
|
|
354
|
+
},
|
|
355
|
+
lineage,
|
|
356
|
+
method: {
|
|
357
|
+
estimator: EDIT_CREDIT_ESTIMATOR,
|
|
358
|
+
credit: `per clean step (a parent and its child on which every change belongs to one edit that introduced the gene), the carrying side against the lacking side on each unit both scored; per unit, both sides averaged over those steps; ${DECISION_PAIRED_DELTA_STATISTIC} paired delta with a ${Math.round(CONFIDENCE * 100)}% percentile bootstrap (${RESAMPLES} resamples, seeded from cellSetDigest): none below 2 units, insufficient below ${DESCRIPTIVE_FROM}, a descriptive interval and exact one-sided sign p below 20, a decision-grade interval from 20; unscored cells are absent, never zero`,
|
|
359
|
+
verdict: `pairedDeltaTest's own decision in each direction: reusable when it finds an improvement (below 20 units an exact one-sided sign test at ${(1 - CONFIDENCE) / 2} with the point estimate on the better side; from 20 the bootstrap interval above zero), harmful when it finds one in the other direction, unresolved otherwise, insufficient below ${DESCRIPTIVE_FROM} units`,
|
|
360
|
+
interaction: `per unit, a gene's clean-step contrast where both ends carry the other gene minus where neither does, averaged over both directions; ${Math.round(CONFIDENCE * 100)}% percentile bootstrap (${RESAMPLES} resamples) for spread; exact two-sided sign test, Holm-adjusted over the pairs tested, flagged at ${INTERACTION_ALPHA}`,
|
|
361
|
+
multiplicity: "credit is not adjusted for the number of genes: it steers which edits to reuse and test next, as selection estimates steer spend, and claims nothing; interaction flags are Holm-adjusted because pairs grow with the square of the genes",
|
|
362
|
+
selectionBias: "a policy chooses a parent for its scores, so a parent is high by chance on the units it was chosen on: on a step where the child gained a gene the lacking side is that parent, and credit is biased toward harmful; where the child lost it, toward reusable. Only the sealed test split is free of this, so a reusable edit still needs a held-out test before it ships"
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
function nodeContent(options, ref) {
|
|
368
|
+
const body = options.readArtifact(ref);
|
|
369
|
+
if (body === void 0) return {
|
|
370
|
+
known: false,
|
|
371
|
+
reason: `artifact ${ref.role} unreadable`
|
|
372
|
+
};
|
|
373
|
+
const paths = /* @__PURE__ */ new Map();
|
|
374
|
+
const record = body;
|
|
375
|
+
if (record && typeof record === "object" && record.kind === "mutable-surface") {
|
|
376
|
+
const surface = record.surface;
|
|
377
|
+
if (typeof surface === "string") addText(paths, "prompt", surface);
|
|
378
|
+
else if (surface?.kind === "components") for (const name of Object.keys(surface.components).sort(compareCodeUnits)) addText(paths, `component:${name}`, surface.components[name]);
|
|
379
|
+
else return {
|
|
380
|
+
known: false,
|
|
381
|
+
reason: "code surface: its patch bytes are not in the ledger"
|
|
382
|
+
};
|
|
383
|
+
} else flatten(body, "", paths);
|
|
384
|
+
const haystacks = /* @__PURE__ */ new Map();
|
|
385
|
+
for (const [path, text] of paths) haystacks.set(path, `\n${text.norm.filter((line) => line.length > 0).join("\n")}\n`);
|
|
386
|
+
return {
|
|
387
|
+
known: true,
|
|
388
|
+
paths,
|
|
389
|
+
haystacks
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
/** JSON as text per location: a string's lines at its path, an array's
|
|
393
|
+
* elements as lines at the array's path, any other value as one canonical
|
|
394
|
+
* line. Array elements share a path, so an insertion does not renumber the
|
|
395
|
+
* rest. */
|
|
396
|
+
function flatten(value, path, paths) {
|
|
397
|
+
const at = path === "" ? "/" : path;
|
|
398
|
+
if (typeof value === "string") addText(paths, at, value);
|
|
399
|
+
else if (Array.isArray(value)) for (const element of value) addText(paths, at, typeof element === "string" ? element : canonicalString(element));
|
|
400
|
+
else if (value !== null && typeof value === "object") for (const key of Object.keys(value).sort(compareCodeUnits)) {
|
|
401
|
+
const escaped = key.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
402
|
+
flatten(value[key], `${path}/${escaped}`, paths);
|
|
403
|
+
}
|
|
404
|
+
else addText(paths, at, canonicalString(value));
|
|
405
|
+
}
|
|
406
|
+
function addText(paths, path, text) {
|
|
407
|
+
const entry = paths.get(path) ?? {
|
|
408
|
+
raw: [],
|
|
409
|
+
norm: []
|
|
410
|
+
};
|
|
411
|
+
for (const line of text.split(/\r?\n/)) {
|
|
412
|
+
entry.raw.push(line);
|
|
413
|
+
entry.norm.push(line.replace(/\s+/g, " ").trim());
|
|
414
|
+
}
|
|
415
|
+
paths.set(path, entry);
|
|
416
|
+
}
|
|
417
|
+
function carries(content, gene) {
|
|
418
|
+
const present = content.haystacks.get(gene.path)?.includes(gene.needle) ?? false;
|
|
419
|
+
return gene.kind === "insert" ? present : !present;
|
|
420
|
+
}
|
|
421
|
+
function locusOf(cut) {
|
|
422
|
+
return canonicalString([cut.path, cut.lines]);
|
|
423
|
+
}
|
|
424
|
+
function edgeCuts(parent, child) {
|
|
425
|
+
const cuts = [];
|
|
426
|
+
const paths = /* @__PURE__ */ new Set([...parent.paths.keys(), ...child.paths.keys()]);
|
|
427
|
+
let position = 0;
|
|
428
|
+
for (const path of [...paths].sort(compareCodeUnits)) {
|
|
429
|
+
const before = parent.paths.get(path) ?? {
|
|
430
|
+
raw: [],
|
|
431
|
+
norm: []
|
|
432
|
+
};
|
|
433
|
+
const after = child.paths.get(path) ?? {
|
|
434
|
+
raw: [],
|
|
435
|
+
norm: []
|
|
436
|
+
};
|
|
437
|
+
for (const run of changeRuns(before.norm, after.norm)) for (const [kind, side, start, end] of [[
|
|
438
|
+
"delete",
|
|
439
|
+
before,
|
|
440
|
+
run.aStart,
|
|
441
|
+
run.aEnd
|
|
442
|
+
], [
|
|
443
|
+
"insert",
|
|
444
|
+
after,
|
|
445
|
+
run.bStart,
|
|
446
|
+
run.bEnd
|
|
447
|
+
]]) for (const [from, to] of paragraphs(side.norm, start, end)) {
|
|
448
|
+
const lines = side.norm.slice(from, to).filter((line) => line.length > 0);
|
|
449
|
+
if (lines.length === 0) continue;
|
|
450
|
+
cuts.push({
|
|
451
|
+
kind,
|
|
452
|
+
path,
|
|
453
|
+
lines,
|
|
454
|
+
needle: `\n${lines.join("\n")}\n`,
|
|
455
|
+
text: side.raw.slice(from, to).join("\n").trim(),
|
|
456
|
+
position: position++
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return cuts;
|
|
461
|
+
}
|
|
462
|
+
/** Paragraph ranges of `lines[start, end)`, split at blank lines. */
|
|
463
|
+
function paragraphs(lines, start, end) {
|
|
464
|
+
const ranges = [];
|
|
465
|
+
let from = -1;
|
|
466
|
+
for (let index = start; index < end; index++) if (lines[index].length === 0) {
|
|
467
|
+
if (from >= 0) ranges.push([from, index]);
|
|
468
|
+
from = -1;
|
|
469
|
+
} else if (from < 0) from = index;
|
|
470
|
+
if (from >= 0) ranges.push([from, end]);
|
|
471
|
+
return ranges;
|
|
472
|
+
}
|
|
473
|
+
/** Maximal runs where `a[aStart, aEnd)` became `b[bStart, bEnd)`, from a
|
|
474
|
+
* shortest edit script (Myers, 1986). */
|
|
475
|
+
function changeRuns(a, b) {
|
|
476
|
+
let prefix = 0;
|
|
477
|
+
while (prefix < a.length && prefix < b.length && a[prefix] === b[prefix]) prefix += 1;
|
|
478
|
+
let suffix = 0;
|
|
479
|
+
while (suffix < a.length - prefix && suffix < b.length - prefix && a[a.length - 1 - suffix] === b[b.length - 1 - suffix]) suffix += 1;
|
|
480
|
+
const innerA = a.slice(prefix, a.length - suffix);
|
|
481
|
+
const innerB = b.slice(prefix, b.length - suffix);
|
|
482
|
+
const runs = [];
|
|
483
|
+
let i = 0;
|
|
484
|
+
let j = 0;
|
|
485
|
+
const flush = (toI, toJ) => {
|
|
486
|
+
if (toI > i || toJ > j) runs.push({
|
|
487
|
+
aStart: prefix + i,
|
|
488
|
+
aEnd: prefix + toI,
|
|
489
|
+
bStart: prefix + j,
|
|
490
|
+
bEnd: prefix + toJ
|
|
491
|
+
});
|
|
492
|
+
};
|
|
493
|
+
for (const [mi, mj] of matches(innerA, innerB)) {
|
|
494
|
+
flush(mi, mj);
|
|
495
|
+
i = mi + 1;
|
|
496
|
+
j = mj + 1;
|
|
497
|
+
}
|
|
498
|
+
flush(innerA.length, innerB.length);
|
|
499
|
+
return runs;
|
|
500
|
+
}
|
|
501
|
+
/** Matched index pairs of a shortest edit script, in increasing order. */
|
|
502
|
+
function matches(a, b) {
|
|
503
|
+
const n = a.length;
|
|
504
|
+
const m = b.length;
|
|
505
|
+
if (n === 0 || m === 0) return [];
|
|
506
|
+
const offset = n + m;
|
|
507
|
+
const v = new Int32Array(2 * offset + 2);
|
|
508
|
+
const trace = [];
|
|
509
|
+
let done = false;
|
|
510
|
+
for (let d = 0; d <= n + m && !done; d++) {
|
|
511
|
+
trace.push(v.slice());
|
|
512
|
+
for (let k = -d; k <= d; k += 2) {
|
|
513
|
+
let x = k === -d || k !== d && v[k - 1 + offset] < v[k + 1 + offset] ? v[k + 1 + offset] : v[k - 1 + offset] + 1;
|
|
514
|
+
let y = x - k;
|
|
515
|
+
while (x < n && y < m && a[x] === b[y]) {
|
|
516
|
+
x += 1;
|
|
517
|
+
y += 1;
|
|
518
|
+
}
|
|
519
|
+
v[k + offset] = x;
|
|
520
|
+
if (x >= n && y >= m) {
|
|
521
|
+
done = true;
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
const pairs = [];
|
|
527
|
+
let x = n;
|
|
528
|
+
let y = m;
|
|
529
|
+
for (let d = trace.length - 1; d >= 0; d--) {
|
|
530
|
+
const before = trace[d];
|
|
531
|
+
const k = x - y;
|
|
532
|
+
const prevK = k === -d || k !== d && before[k - 1 + offset] < before[k + 1 + offset] ? k + 1 : k - 1;
|
|
533
|
+
const prevX = before[prevK + offset];
|
|
534
|
+
const prevY = prevX - prevK;
|
|
535
|
+
while (x > prevX && y > prevY) {
|
|
536
|
+
x -= 1;
|
|
537
|
+
y -= 1;
|
|
538
|
+
pairs.push([x, y]);
|
|
539
|
+
}
|
|
540
|
+
if (d > 0) {
|
|
541
|
+
x = prevX;
|
|
542
|
+
y = prevY;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return pairs.reverse();
|
|
546
|
+
}
|
|
547
|
+
/** Parents of an edge that precede its child in this search: the edge's
|
|
548
|
+
* lineage. A re-proposal from the child itself or a later node adds none. */
|
|
549
|
+
function lineageParents(state, edge, child, byId) {
|
|
550
|
+
const parents = [];
|
|
551
|
+
for (const parent of edge.parents) {
|
|
552
|
+
if (parent.searchId !== state.searchId) continue;
|
|
553
|
+
const node = byId.get(parent.nodeId);
|
|
554
|
+
if (node && node.ordinal < child.ordinal && !parents.includes(node.nodeId)) parents.push(node.nodeId);
|
|
555
|
+
}
|
|
556
|
+
return parents;
|
|
557
|
+
}
|
|
558
|
+
function lineageRows(state, nodes, byId, contents, carried) {
|
|
559
|
+
const rows = [];
|
|
560
|
+
const placed = /* @__PURE__ */ new Set();
|
|
561
|
+
const visit = (root) => {
|
|
562
|
+
const stack = [root];
|
|
563
|
+
while (stack.length > 0) {
|
|
564
|
+
const node = stack.pop();
|
|
565
|
+
if (placed.has(node.nodeId)) continue;
|
|
566
|
+
placed.add(node.nodeId);
|
|
567
|
+
const firstEdge = node.edgeIds[0] ? state.edge(node.edgeIds[0]) : void 0;
|
|
568
|
+
rows.push({
|
|
569
|
+
nodeId: node.nodeId,
|
|
570
|
+
ordinal: node.ordinal,
|
|
571
|
+
depth: node.depth,
|
|
572
|
+
primaryParentId: node.primaryParentId,
|
|
573
|
+
parents: node.parents.filter((parent) => parent.searchId === state.searchId).map((parent) => parent.nodeId),
|
|
574
|
+
operator: firstEdge?.operator ?? null,
|
|
575
|
+
status: node.status,
|
|
576
|
+
carries: [...carried.get(node.nodeId) ?? []].sort(compareCodeUnits),
|
|
577
|
+
contentKnown: contents.get(node.nodeId).known
|
|
578
|
+
});
|
|
579
|
+
const children = node.children.map((id) => byId.get(id)).filter((child) => child.primaryParentId === node.nodeId).sort((a, b) => b.ordinal - a.ordinal);
|
|
580
|
+
stack.push(...children);
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
for (const node of nodes) if (node.primaryParentId === null) visit(node);
|
|
584
|
+
for (const node of nodes) visit(node);
|
|
585
|
+
return rows;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* The paired contrast of per-unit carrier means against per-unit lacking
|
|
589
|
+
* means, staged and oriented exactly as `estimateNodeFromCells` stages and
|
|
590
|
+
* orients a node against another, with the verdict `pairedDeltaTest` itself
|
|
591
|
+
* would reach in each direction: below 20 units the exact one-sided sign test
|
|
592
|
+
* at α = 0.025 and a point estimate on the same side, from 20 the bootstrap
|
|
593
|
+
* interval clear of zero.
|
|
594
|
+
*/
|
|
595
|
+
function pairedEstimate(input) {
|
|
596
|
+
const pairs = input.carrier.length;
|
|
597
|
+
const method = methodFor(pairs);
|
|
598
|
+
const base = {
|
|
599
|
+
against: input.against,
|
|
600
|
+
split: input.split,
|
|
601
|
+
units: input.units,
|
|
602
|
+
pairs,
|
|
603
|
+
method,
|
|
604
|
+
cellSetDigest: input.cellSetDigest,
|
|
605
|
+
estimator: EDIT_CREDIT_ESTIMATOR
|
|
606
|
+
};
|
|
607
|
+
if (method === "none") return {
|
|
608
|
+
credit: {
|
|
609
|
+
...base,
|
|
610
|
+
delta: null,
|
|
611
|
+
interval: null,
|
|
612
|
+
exactSignP: null,
|
|
613
|
+
indeterminate: false
|
|
614
|
+
},
|
|
615
|
+
verdict: "insufficient"
|
|
616
|
+
};
|
|
617
|
+
const maximize = input.direction === "maximize";
|
|
618
|
+
const before = maximize ? input.lacking : input.carrier;
|
|
619
|
+
const after = maximize ? input.carrier : input.lacking;
|
|
620
|
+
const options = {
|
|
621
|
+
statistic: DECISION_PAIRED_DELTA_STATISTIC,
|
|
622
|
+
confidence: CONFIDENCE,
|
|
623
|
+
resamples: RESAMPLES,
|
|
624
|
+
seed: seedFromDigest(input.cellSetDigest)
|
|
625
|
+
};
|
|
626
|
+
const better = pairedDeltaTest(before, after, options);
|
|
627
|
+
const worse = pairedDeltaTest(after, before, options);
|
|
628
|
+
const { mean, low, high } = better.bootstrap;
|
|
629
|
+
const indeterminate = better.indeterminate;
|
|
630
|
+
const spread = (method === "descriptive" || method === "bootstrap") && !indeterminate;
|
|
631
|
+
return {
|
|
632
|
+
credit: {
|
|
633
|
+
...base,
|
|
634
|
+
delta: plain(maximize ? mean : -mean),
|
|
635
|
+
interval: spread ? maximize ? [plain(low), plain(high)] : [plain(-high), plain(-low)] : null,
|
|
636
|
+
exactSignP: method === "descriptive" && !indeterminate ? better.pValue : null,
|
|
637
|
+
indeterminate
|
|
638
|
+
},
|
|
639
|
+
verdict: method === "insufficient" ? "insufficient" : better.significant ? "reusable" : worse.significant ? "harmful" : "unresolved"
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
/** The chance, with no true effect, that the verdict on `pairs` units is
|
|
643
|
+
* reusable: the exact sign test's attained size (the upper tail at the
|
|
644
|
+
* smallest positive count that clears α) below the bootstrap gate, the
|
|
645
|
+
* percentile bootstrap's nominal one-sided α from it. */
|
|
646
|
+
const chanceReusable = (() => {
|
|
647
|
+
const alpha = (1 - CONFIDENCE) / 2;
|
|
648
|
+
const cache = /* @__PURE__ */ new Map();
|
|
649
|
+
return (pairs) => {
|
|
650
|
+
if (pairs < DESCRIPTIVE_FROM) return 0;
|
|
651
|
+
if (pairs >= 20) return alpha;
|
|
652
|
+
const cached = cache.get(pairs);
|
|
653
|
+
if (cached !== void 0) return cached;
|
|
654
|
+
let size = 0;
|
|
655
|
+
for (let positive = 0; positive <= pairs; positive++) {
|
|
656
|
+
const p = pairedSignTest(Array.from({ length: pairs }, (_, index) => index < positive ? 1 : -1), "greater").pValue;
|
|
657
|
+
if (p <= alpha) {
|
|
658
|
+
size = p;
|
|
659
|
+
break;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
cache.set(pairs, size);
|
|
663
|
+
return size;
|
|
664
|
+
};
|
|
665
|
+
})();
|
|
666
|
+
function methodFor(units) {
|
|
667
|
+
if (units < 2) return "none";
|
|
668
|
+
if (units < DESCRIPTIVE_FROM) return "insufficient";
|
|
669
|
+
if (units < 20) return "descriptive";
|
|
670
|
+
return "bootstrap";
|
|
671
|
+
}
|
|
672
|
+
/** The worse end of the interval, as an improvement; -Infinity without one. */
|
|
673
|
+
function improvementLow(credit, sign) {
|
|
674
|
+
if (credit.interval === null) return Number.NEGATIVE_INFINITY;
|
|
675
|
+
return sign > 0 ? credit.interval[0] : -credit.interval[1];
|
|
676
|
+
}
|
|
677
|
+
function gain(credit, sign) {
|
|
678
|
+
return credit.delta === null ? Number.NEGATIVE_INFINITY : sign * credit.delta;
|
|
679
|
+
}
|
|
680
|
+
function interactionPairs(input) {
|
|
681
|
+
const seenGroups = /* @__PURE__ */ new Set();
|
|
682
|
+
const considered = [...input.genes].filter((gene) => {
|
|
683
|
+
const group = gene.linkage ?? gene.geneId;
|
|
684
|
+
if (seenGroups.has(group)) return false;
|
|
685
|
+
seenGroups.add(group);
|
|
686
|
+
return gene.credit.pairs >= DESCRIPTIVE_FROM;
|
|
687
|
+
}).sort((a, b) => b.credit.pairs - a.credit.pairs || compareCodeUnits(a.geneId, b.geneId)).slice(0, input.limit);
|
|
688
|
+
/** Per unit, the mean clean-step contrast of `geneId` over `steps`. */
|
|
689
|
+
const contrasts = (steps) => {
|
|
690
|
+
const sums = /* @__PURE__ */ new Map();
|
|
691
|
+
for (const { carrierId, lackingId } of steps) {
|
|
692
|
+
const lacking = input.unitMeansOf(lackingId);
|
|
693
|
+
for (const [unitId, carrierMean] of input.unitMeansOf(carrierId)) {
|
|
694
|
+
const lackingMean = lacking.get(unitId);
|
|
695
|
+
if (lackingMean === void 0) continue;
|
|
696
|
+
const entry = sums.get(unitId) ?? {
|
|
697
|
+
sum: 0,
|
|
698
|
+
count: 0
|
|
699
|
+
};
|
|
700
|
+
entry.sum += carrierMean - lackingMean;
|
|
701
|
+
entry.count += 1;
|
|
702
|
+
sums.set(unitId, entry);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return new Map([...sums].map(([unitId, entry]) => [unitId, entry.sum / entry.count]));
|
|
706
|
+
};
|
|
707
|
+
/** `geneId`'s clean steps split by whether both ends carry `other`. */
|
|
708
|
+
const split = (geneId, other) => {
|
|
709
|
+
const carriers = input.carriersOf.get(other);
|
|
710
|
+
const steps = input.cleanOf.get(geneId) ?? [];
|
|
711
|
+
return {
|
|
712
|
+
withOther: steps.filter(({ step }) => carriers.has(step.parentId) && carriers.has(step.childId)),
|
|
713
|
+
withoutOther: steps.filter(({ step }) => !carriers.has(step.parentId) && !carriers.has(step.childId))
|
|
714
|
+
};
|
|
715
|
+
};
|
|
716
|
+
const pairs = [];
|
|
717
|
+
for (let i = 0; i < considered.length; i++) for (let j = i + 1; j < considered.length; j++) {
|
|
718
|
+
const first = considered[i];
|
|
719
|
+
const second = considered[j];
|
|
720
|
+
if (first.edit.includes(second.geneId) || second.edit.includes(first.geneId)) continue;
|
|
721
|
+
const firstSplit = split(first.geneId, second.geneId);
|
|
722
|
+
const secondSplit = split(second.geneId, first.geneId);
|
|
723
|
+
const steps = {
|
|
724
|
+
firstWithSecond: firstSplit.withOther.length,
|
|
725
|
+
firstWithoutSecond: firstSplit.withoutOther.length,
|
|
726
|
+
secondWithFirst: secondSplit.withOther.length,
|
|
727
|
+
secondWithoutFirst: secondSplit.withoutOther.length
|
|
728
|
+
};
|
|
729
|
+
const directions = [firstSplit, secondSplit].filter((direction) => direction.withOther.length > 0 && direction.withoutOther.length > 0);
|
|
730
|
+
if (directions.length === 0) continue;
|
|
731
|
+
const perUnit = /* @__PURE__ */ new Map();
|
|
732
|
+
for (const direction of directions) {
|
|
733
|
+
const withMeans = contrasts(direction.withOther);
|
|
734
|
+
const withoutMeans = contrasts(direction.withoutOther);
|
|
735
|
+
for (const [unitId, withMean] of withMeans) {
|
|
736
|
+
const withoutMean = withoutMeans.get(unitId);
|
|
737
|
+
if (withoutMean === void 0) continue;
|
|
738
|
+
const entry = perUnit.get(unitId) ?? {
|
|
739
|
+
sum: 0,
|
|
740
|
+
count: 0
|
|
741
|
+
};
|
|
742
|
+
entry.sum += withMean - withoutMean;
|
|
743
|
+
entry.count += 1;
|
|
744
|
+
perUnit.set(unitId, entry);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
const values = [...perUnit.keys()].sort(compareCodeUnits).map((unitId) => perUnit.get(unitId).sum / perUnit.get(unitId).count);
|
|
748
|
+
pairs.push(interactionEstimate([first.geneId, second.geneId], steps, values, input.sign));
|
|
749
|
+
}
|
|
750
|
+
const tested = pairs.filter((pair) => pair.signP !== null);
|
|
751
|
+
const adjusted = holm(tested.map((pair) => pair.signP), INTERACTION_ALPHA);
|
|
752
|
+
tested.forEach((pair, index) => {
|
|
753
|
+
pair.adjustedP = adjusted.adjusted[index];
|
|
754
|
+
pair.interacting = adjusted.significant[index];
|
|
755
|
+
});
|
|
756
|
+
pairs.sort((a, b) => Number(b.interacting) - Number(a.interacting) || (a.adjustedP ?? 2) - (b.adjustedP ?? 2) || b.units - a.units || compareCodeUnits(a.genes.join(), b.genes.join()));
|
|
757
|
+
return {
|
|
758
|
+
genesConsidered: considered.length,
|
|
759
|
+
pairsWithContexts: pairs.length,
|
|
760
|
+
pairsTested: tested.length,
|
|
761
|
+
alpha: INTERACTION_ALPHA,
|
|
762
|
+
correction: "holm",
|
|
763
|
+
pairs
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
function interactionEstimate(genes, steps, values, sign) {
|
|
767
|
+
const units = values.length;
|
|
768
|
+
const method = methodFor(units);
|
|
769
|
+
const base = {
|
|
770
|
+
genes,
|
|
771
|
+
steps,
|
|
772
|
+
units,
|
|
773
|
+
method,
|
|
774
|
+
adjustedP: null,
|
|
775
|
+
interacting: false
|
|
776
|
+
};
|
|
777
|
+
if (method === "none") return {
|
|
778
|
+
...base,
|
|
779
|
+
interaction: null,
|
|
780
|
+
interval: null,
|
|
781
|
+
indeterminate: false,
|
|
782
|
+
signP: null,
|
|
783
|
+
synergy: null
|
|
784
|
+
};
|
|
785
|
+
const interaction = plain(values.reduce((sum, value) => sum + value, 0) / units);
|
|
786
|
+
if (method === "insufficient") return {
|
|
787
|
+
...base,
|
|
788
|
+
interaction,
|
|
789
|
+
interval: null,
|
|
790
|
+
indeterminate: false,
|
|
791
|
+
signP: null,
|
|
792
|
+
synergy: sign * interaction > 0
|
|
793
|
+
};
|
|
794
|
+
const test = pairedDeltaTest(values.map(() => 0), values, {
|
|
795
|
+
statistic: DECISION_PAIRED_DELTA_STATISTIC,
|
|
796
|
+
confidence: CONFIDENCE,
|
|
797
|
+
resamples: RESAMPLES,
|
|
798
|
+
seed: seedFromDigest(hashCanonical({
|
|
799
|
+
genes,
|
|
800
|
+
values
|
|
801
|
+
}))
|
|
802
|
+
});
|
|
803
|
+
const indeterminate = test.indeterminate;
|
|
804
|
+
const greater = pairedSignTest(values, "greater").pValue;
|
|
805
|
+
const less = pairedSignTest(values, "less").pValue;
|
|
806
|
+
return {
|
|
807
|
+
...base,
|
|
808
|
+
interaction,
|
|
809
|
+
interval: indeterminate ? null : [plain(test.bootstrap.low), plain(test.bootstrap.high)],
|
|
810
|
+
indeterminate,
|
|
811
|
+
signP: indeterminate ? null : Math.min(1, 2 * Math.min(greater, less)),
|
|
812
|
+
synergy: sign * interaction > 0
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
function skills(genes, records, subject, split) {
|
|
816
|
+
const byGroup = /* @__PURE__ */ new Map();
|
|
817
|
+
for (const gene of genes) {
|
|
818
|
+
if (gene.verdict !== "reusable" || gene.kind !== "insert") continue;
|
|
819
|
+
appendTo(byGroup, gene.linkage ?? gene.geneId, gene);
|
|
820
|
+
}
|
|
821
|
+
const candidates = [];
|
|
822
|
+
for (const members of byGroup.values()) {
|
|
823
|
+
members.sort((a, b) => compareOrder(records.get(a.geneId).order, records.get(b.geneId).order));
|
|
824
|
+
const lead = members[0];
|
|
825
|
+
const name = `edit-${lead.geneId.slice(5, 17)}`;
|
|
826
|
+
const content = [
|
|
827
|
+
"---",
|
|
828
|
+
`name: ${name}`,
|
|
829
|
+
`description: ${JSON.stringify(`Instructions a ${subject} search kept: the edit to ${lead.path} whose carriers scored better than the nodes without it on the ${split} split.`)}`,
|
|
830
|
+
"---",
|
|
831
|
+
"",
|
|
832
|
+
members.map((gene) => gene.text).join("\n\n"),
|
|
833
|
+
""
|
|
834
|
+
].join("\n");
|
|
835
|
+
candidates.push({
|
|
836
|
+
resource: defineInlineResource(name, content),
|
|
837
|
+
improveOptions: {
|
|
838
|
+
surface: "skills",
|
|
839
|
+
skills: { resourceName: name }
|
|
840
|
+
},
|
|
841
|
+
genes: members.map((gene) => gene.geneId),
|
|
842
|
+
path: lead.path,
|
|
843
|
+
credit: lead.credit
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
return candidates;
|
|
847
|
+
}
|
|
848
|
+
/** The lens as compact text: what `agent-eval search show --edit-credit`
|
|
849
|
+
* prints and a proposer can read. */
|
|
850
|
+
function editCreditText(result, options = {}) {
|
|
851
|
+
const limit = options.limit ?? 10;
|
|
852
|
+
const { data, signal } = result;
|
|
853
|
+
const unknown = Object.entries(data.edges.unknown).map(([reason, count]) => `${count} ${reason}`).join(", ");
|
|
854
|
+
const lines = [
|
|
855
|
+
`edit credit — ${data.genes.length} genes from ${data.edges.read} of ${data.edges.lineage} lineage edges, ${data.steps.total - data.steps.invalidEnd} measurable steps (${data.split} split, ${data.direction})`,
|
|
856
|
+
` genes: reusable ${data.counts.reusable} · harmful ${data.counts.harmful} · unresolved ${data.counts.unresolved} · insufficient ${data.counts.insufficient}`,
|
|
857
|
+
` edits: reusable ${data.editCounts.reusable} · harmful ${data.editCounts.harmful} · unresolved ${data.editCounts.unresolved} · insufficient ${data.editCounts.insufficient}`,
|
|
858
|
+
` signal ${signal.name} = ${signal.value ?? "null"} (${signal.basis})${signal.top.length > 0 ? `; top: ${signal.top.slice(0, 5).join(", ")}` : ""}`
|
|
859
|
+
];
|
|
860
|
+
if (unknown.length > 0) lines.push(` edges without content: ${unknown}`);
|
|
861
|
+
if (data.nodes.invalid > 0) lines.push(` ${data.nodes.invalid} invalid node(s): ${data.steps.invalidEnd} step(s) excluded from every contrast`);
|
|
862
|
+
lines.push("", "Genes (carrying vs lacking side of each clean lineage step):");
|
|
863
|
+
if (data.genes.length === 0) lines.push(" none");
|
|
864
|
+
for (const gene of data.genes.slice(0, limit)) {
|
|
865
|
+
const reproposed = gene.introduced.length > 1 ? ` · introduced ${gene.introduced.length}×` : "";
|
|
866
|
+
const dropped = gene.dropped > 0 ? ` · dropped ${gene.dropped}×` : "";
|
|
867
|
+
const linked = gene.linkage && gene.linkage !== gene.geneId ? ` · linked to ${gene.linkage}` : "";
|
|
868
|
+
const invalid = gene.invalidCarriers > 0 ? ` · ${gene.invalidCarriers} invalid carrier(s)` : "";
|
|
869
|
+
lines.push(` ${gene.geneId} ${gene.verdict} ${gene.kind} ${gene.path} "${excerpt(gene.lines)}"`, ` ${gene.steps.clean} clean of ${gene.steps.flips} step(s) (+${gene.steps.gained}/−${gene.steps.lost}) · ${formatEstimate(gene.credit)}${reproposed}${dropped}${linked}${invalid}`);
|
|
870
|
+
}
|
|
871
|
+
if (data.genes.length > limit) lines.push(` … ${data.genes.length - limit} more`);
|
|
872
|
+
const { interactions } = data;
|
|
873
|
+
lines.push("", `Interactions: ${interactions.pairsTested} pair(s) tested of ${interactions.pairsWithContexts} with both contexts, among ${interactions.genesConsidered} genes (Holm, α=${interactions.alpha}):`);
|
|
874
|
+
const shown = interactions.pairs.filter((pair) => pair.signP !== null).slice(0, limit);
|
|
875
|
+
if (shown.length === 0) lines.push(" none testable");
|
|
876
|
+
for (const pair of shown) {
|
|
877
|
+
const interval = pair.interval ? ` [${pair.interval[0].toFixed(4)}, ${pair.interval[1].toFixed(4)}]` : "";
|
|
878
|
+
lines.push(` ${pair.genes[0]} × ${pair.genes[1]}: ${pair.interacting ? "INTERACTING" : "no flag"} ${signed$1(pair.interaction)}${interval} on ${pair.units} units, sign p=${pair.signP.toPrecision(3)}, Holm p=${pair.adjustedP.toPrecision(3)}`);
|
|
879
|
+
}
|
|
880
|
+
lines.push("", `Skill candidates (agent-runtime improve surface 'skills'):`);
|
|
881
|
+
if (data.skillCandidates.length === 0) lines.push(" none");
|
|
882
|
+
for (const candidate of data.skillCandidates.slice(0, limit)) lines.push(` ${candidate.resource.name}: ${candidate.genes.length} gene(s) at ${candidate.path} · ${formatEstimate(candidate.credit)}`);
|
|
883
|
+
return lines.join("\n");
|
|
884
|
+
}
|
|
885
|
+
function excerpt(lines) {
|
|
886
|
+
const text = lines.join(" ⏎ ");
|
|
887
|
+
return text.length <= 72 ? text : `${text.slice(0, 71)}…`;
|
|
888
|
+
}
|
|
889
|
+
function signed$1(value) {
|
|
890
|
+
if (value === null) return "null";
|
|
891
|
+
return `${value >= 0 ? "+" : ""}${value.toFixed(4)}`;
|
|
892
|
+
}
|
|
893
|
+
function formatEstimate(estimate) {
|
|
894
|
+
const units = `${estimate.pairs} unit${estimate.pairs === 1 ? "" : "s"}`;
|
|
895
|
+
if (estimate.method === "none") return `unknown (${units})`;
|
|
896
|
+
if (estimate.indeterminate) return `no measurable difference (indeterminate, ${units})`;
|
|
897
|
+
const delta = `Δ=${signed$1(estimate.delta)}`;
|
|
898
|
+
if (estimate.method === "insufficient") return `${delta} (${estimate.pairs} of ${DESCRIPTIVE_FROM} units, insufficient)`;
|
|
899
|
+
const interval = estimate.interval ? ` [${estimate.interval[0].toFixed(4)}, ${estimate.interval[1].toFixed(4)}]` : "";
|
|
900
|
+
if (estimate.method === "descriptive") return `${delta}${interval} descriptive, sign p=${estimate.exactSignP === null ? "null" : estimate.exactSignP.toPrecision(3)} (${units})`;
|
|
901
|
+
return `${delta}${interval} bootstrap (${units})`;
|
|
902
|
+
}
|
|
903
|
+
function emptyCounts() {
|
|
904
|
+
return {
|
|
905
|
+
reusable: 0,
|
|
906
|
+
harmful: 0,
|
|
907
|
+
unresolved: 0,
|
|
908
|
+
insufficient: 0
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
function appendTo(map, key, value) {
|
|
912
|
+
const list = map.get(key);
|
|
913
|
+
if (list) list.push(value);
|
|
914
|
+
else map.set(key, [value]);
|
|
915
|
+
}
|
|
916
|
+
function memo(compute) {
|
|
917
|
+
const cache = /* @__PURE__ */ new Map();
|
|
918
|
+
return (key) => {
|
|
919
|
+
let value = cache.get(key);
|
|
920
|
+
if (value === void 0) {
|
|
921
|
+
value = compute(key);
|
|
922
|
+
cache.set(key, value);
|
|
923
|
+
}
|
|
924
|
+
return value;
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
function compareOrder(left, right) {
|
|
928
|
+
return left[0] - right[0] || left[1] - right[1];
|
|
929
|
+
}
|
|
930
|
+
/** Canonical JSON has one zero; never hand it a negative one. */
|
|
931
|
+
function plain(value) {
|
|
932
|
+
return value === 0 ? 0 : value;
|
|
933
|
+
}
|
|
934
|
+
//#endregion
|
|
935
|
+
//#region src/search/lenses/front.ts
|
|
936
|
+
function front(state, options = {}) {
|
|
937
|
+
const split = options.split ?? rankingSplit(state);
|
|
938
|
+
const direction = state.header?.objective.direction ?? "maximize";
|
|
939
|
+
const extraAxes = options.axes ?? [];
|
|
940
|
+
const rows = state.nodes().map((node) => {
|
|
941
|
+
const units = state.unitScores(node.nodeId, split);
|
|
942
|
+
const score = units.length > 0 ? units.reduce((sum, u) => sum + u.mean, 0) / units.length : null;
|
|
943
|
+
return {
|
|
944
|
+
nodeId: node.nodeId,
|
|
945
|
+
score,
|
|
946
|
+
knownCostUsd: node.spend.knownUsd,
|
|
947
|
+
costKnown: node.spend.unknownCostCells === 0,
|
|
948
|
+
extra: extraAxes.map((axis) => axis.value(state, node.nodeId))
|
|
949
|
+
};
|
|
950
|
+
});
|
|
951
|
+
const eligible = rows.filter((row) => row.score !== null && row.costKnown && row.extra.every((value) => Number.isFinite(value)));
|
|
952
|
+
const objectives = [
|
|
953
|
+
{
|
|
954
|
+
name: "score",
|
|
955
|
+
direction,
|
|
956
|
+
value: (row) => row.score
|
|
957
|
+
},
|
|
958
|
+
{
|
|
959
|
+
name: "knownCostUsd",
|
|
960
|
+
direction: "minimize",
|
|
961
|
+
value: (row) => row.knownCostUsd
|
|
962
|
+
},
|
|
963
|
+
...extraAxes.map((axis, index) => ({
|
|
964
|
+
name: axis.name,
|
|
965
|
+
direction: axis.direction,
|
|
966
|
+
value: (row) => row.extra[index]
|
|
967
|
+
}))
|
|
968
|
+
];
|
|
969
|
+
const frontierIds = eligible.length > 0 ? new Set(paretoFrontier(eligible, objectives).frontier.map((r) => r.nodeId)) : /* @__PURE__ */ new Set();
|
|
970
|
+
const outRows = rows.map((row) => ({
|
|
971
|
+
nodeId: row.nodeId,
|
|
972
|
+
score: row.score,
|
|
973
|
+
knownCostUsd: row.knownCostUsd,
|
|
974
|
+
costKnown: row.costKnown,
|
|
975
|
+
onFront: frontierIds.has(row.nodeId)
|
|
976
|
+
}));
|
|
977
|
+
const signalValue = {};
|
|
978
|
+
for (const row of outRows) signalValue[row.nodeId] = row.onFront ? 1 : 0;
|
|
979
|
+
return {
|
|
980
|
+
data: {
|
|
981
|
+
split,
|
|
982
|
+
axes: objectives.map((o) => o.name),
|
|
983
|
+
rows: outRows,
|
|
984
|
+
frontierSize: frontierIds.size,
|
|
985
|
+
excludedCount: rows.length - eligible.length
|
|
986
|
+
},
|
|
987
|
+
signal: {
|
|
988
|
+
name: "front.membership",
|
|
989
|
+
value: signalValue
|
|
990
|
+
}
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
/** The method `estimateNode` reports for a paired sample of `pairs` units. */
|
|
994
|
+
function estimateMethodFor(pairs) {
|
|
995
|
+
if (pairs < 2) return "none";
|
|
996
|
+
if (pairs < INSUFFICIENT_FROM) return "insufficient";
|
|
997
|
+
if (pairs < 20) return "descriptive";
|
|
998
|
+
return "bootstrap";
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Nodes whose screen finished, in registration order, root first: the
|
|
1002
|
+
* ledger's reading of the kernel's `screened` list. A node counts once it is
|
|
1003
|
+
* not decided invalid, has an edge, and every cell it was allocated outside a
|
|
1004
|
+
* rung or the claim can no longer run (scored, final, cancelled, or errored
|
|
1005
|
+
* with no attempt left). Cells a rung opened later do not un-screen it.
|
|
1006
|
+
*/
|
|
1007
|
+
function screenedNodes(state, options = {}) {
|
|
1008
|
+
const maxAttempts = options.maxAttempts ?? 3;
|
|
1009
|
+
const screened = [];
|
|
1010
|
+
for (const node of state.nodes()) {
|
|
1011
|
+
if (node.status === "invalid" || node.edgeIds.length === 0) continue;
|
|
1012
|
+
let allocated = 0;
|
|
1013
|
+
let open = false;
|
|
1014
|
+
for (const cell of state.cells({ nodeId: node.nodeId })) {
|
|
1015
|
+
if (cell.stage === "rung" || cell.stage === "claim") continue;
|
|
1016
|
+
allocated += 1;
|
|
1017
|
+
if (!(cell.final || cell.cancelled !== null || cell.outcome === "errored" && cell.attempts >= maxAttempts)) {
|
|
1018
|
+
open = true;
|
|
1019
|
+
break;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
if (allocated > 0 && !open) screened.push(node.nodeId);
|
|
1023
|
+
}
|
|
1024
|
+
return screened;
|
|
1025
|
+
}
|
|
1026
|
+
/** The head's sequence, or -1 before the first entry. */
|
|
1027
|
+
function headSequence(state) {
|
|
1028
|
+
return state.head?.sequence ?? -1;
|
|
1029
|
+
}
|
|
1030
|
+
/** Rounded for JSON a person reads; 9 decimals keep the bits a renderer
|
|
1031
|
+
* needs and drop float noise. */
|
|
1032
|
+
function round9(value) {
|
|
1033
|
+
const rounded = Math.round(value * 1e9) / 1e9;
|
|
1034
|
+
return rounded === 0 ? 0 : rounded;
|
|
1035
|
+
}
|
|
1036
|
+
function median(values) {
|
|
1037
|
+
if (values.length === 0) return null;
|
|
1038
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
1039
|
+
const middle = Math.floor(sorted.length / 2);
|
|
1040
|
+
return sorted.length % 2 === 1 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
|
|
1041
|
+
}
|
|
1042
|
+
/** At most `cap` items, evenly spaced by position: a deterministic sample
|
|
1043
|
+
* that keeps a quadratic step bounded on a large search. */
|
|
1044
|
+
function evenSample(items, cap) {
|
|
1045
|
+
if (items.length <= cap) return [...items];
|
|
1046
|
+
return Array.from({ length: cap }, (_, index) => items[Math.floor(index * items.length / cap)]);
|
|
1047
|
+
}
|
|
1048
|
+
function positiveInteger(owner, name, value) {
|
|
1049
|
+
if (!Number.isSafeInteger(value) || value < 1) throw new TypeError(`${owner}: ${name} must be a positive integer, got ${String(value)}`);
|
|
1050
|
+
return value;
|
|
1051
|
+
}
|
|
1052
|
+
function plural$1(count, noun) {
|
|
1053
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
1054
|
+
}
|
|
1055
|
+
//#endregion
|
|
1056
|
+
//#region src/math/cholesky.ts
|
|
1057
|
+
/**
|
|
1058
|
+
* Cholesky factorization and solves for small symmetric positive-definite
|
|
1059
|
+
* systems (Golub and Van Loan, Matrix Computations, 4th ed., §4.2), the
|
|
1060
|
+
* ridge normal equations the skill-manifold lens solves once per row and
|
|
1061
|
+
* column of each alternating least-squares sweep.
|
|
1062
|
+
*/
|
|
1063
|
+
/** The lower-triangular L with `matrix = L Lᵀ`, or null when the matrix is
|
|
1064
|
+
* not numerically positive definite. */
|
|
1065
|
+
function cholesky(matrix) {
|
|
1066
|
+
const n = matrix.length;
|
|
1067
|
+
const lower = Array.from({ length: n }, () => new Array(n).fill(0));
|
|
1068
|
+
for (let j = 0; j < n; j++) {
|
|
1069
|
+
let diagonal = matrix[j][j];
|
|
1070
|
+
for (let k = 0; k < j; k++) diagonal -= lower[j][k] ** 2;
|
|
1071
|
+
if (!(diagonal > 0) || !Number.isFinite(diagonal)) return null;
|
|
1072
|
+
const pivot = Math.sqrt(diagonal);
|
|
1073
|
+
lower[j][j] = pivot;
|
|
1074
|
+
for (let i = j + 1; i < n; i++) {
|
|
1075
|
+
let value = matrix[i][j];
|
|
1076
|
+
for (let k = 0; k < j; k++) value -= lower[i][k] * lower[j][k];
|
|
1077
|
+
lower[i][j] = value / pivot;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
return lower;
|
|
1081
|
+
}
|
|
1082
|
+
/** Solves `L Lᵀ x = b` for x given the Cholesky factor L. */
|
|
1083
|
+
function choleskySolve(lower, b) {
|
|
1084
|
+
const n = lower.length;
|
|
1085
|
+
const y = new Array(n).fill(0);
|
|
1086
|
+
for (let i = 0; i < n; i++) {
|
|
1087
|
+
let value = b[i];
|
|
1088
|
+
for (let k = 0; k < i; k++) value -= lower[i][k] * y[k];
|
|
1089
|
+
y[i] = value / lower[i][i];
|
|
1090
|
+
}
|
|
1091
|
+
const x = new Array(n).fill(0);
|
|
1092
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
1093
|
+
let value = y[i];
|
|
1094
|
+
for (let k = i + 1; k < n; k++) value -= lower[k][i] * x[k];
|
|
1095
|
+
x[i] = value / lower[i][i];
|
|
1096
|
+
}
|
|
1097
|
+
return x;
|
|
1098
|
+
}
|
|
1099
|
+
/** The inverse of a symmetric positive-definite matrix from its factor. */
|
|
1100
|
+
function choleskyInverse(lower) {
|
|
1101
|
+
const n = lower.length;
|
|
1102
|
+
const columns = Array.from({ length: n }, (_, j) => choleskySolve(lower, Array.from({ length: n }, (_, i) => i === j ? 1 : 0)));
|
|
1103
|
+
return Array.from({ length: n }, (_, i) => columns.map((column) => column[i]));
|
|
1104
|
+
}
|
|
1105
|
+
//#endregion
|
|
1106
|
+
//#region src/math/symmetric-eigen.ts
|
|
1107
|
+
const MAX_SWEEPS = 100;
|
|
1108
|
+
/** Eigenvalues and eigenvectors of a symmetric matrix, largest value first. */
|
|
1109
|
+
function symmetricEigen(matrix) {
|
|
1110
|
+
const n = matrix.length;
|
|
1111
|
+
const a = matrix.map((row, index) => {
|
|
1112
|
+
if (row.length !== n) throw new RangeError(`symmetricEigen: row ${index} has ${row.length} entries, expected ${n}`);
|
|
1113
|
+
return row.map((value) => {
|
|
1114
|
+
if (!Number.isFinite(value)) throw new RangeError("symmetricEigen: entries must be finite");
|
|
1115
|
+
return value;
|
|
1116
|
+
});
|
|
1117
|
+
});
|
|
1118
|
+
for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) {
|
|
1119
|
+
const scale = Math.max(1, Math.abs(a[i][j]), Math.abs(a[j][i]));
|
|
1120
|
+
if (Math.abs(a[i][j] - a[j][i]) > 1e-9 * scale) throw new RangeError(`symmetricEigen: the matrix is not symmetric at (${i}, ${j})`);
|
|
1121
|
+
}
|
|
1122
|
+
const v = Array.from({ length: n }, (_, i) => Array.from({ length: n }, (_, j) => i === j ? 1 : 0));
|
|
1123
|
+
let total = 0;
|
|
1124
|
+
for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) total += a[i][j] ** 2;
|
|
1125
|
+
const tolerance = 1e-24 * Math.max(total, Number.MIN_VALUE);
|
|
1126
|
+
for (let sweep = 0; sweep < MAX_SWEEPS; sweep++) {
|
|
1127
|
+
let off = 0;
|
|
1128
|
+
for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) off += a[i][j] ** 2;
|
|
1129
|
+
if (off <= tolerance) break;
|
|
1130
|
+
for (let p = 0; p < n; p++) for (let q = p + 1; q < n; q++) {
|
|
1131
|
+
const apq = a[p][q];
|
|
1132
|
+
if (apq === 0) continue;
|
|
1133
|
+
const theta = (a[q][q] - a[p][p]) / (2 * apq);
|
|
1134
|
+
const t = Math.sign(theta || 1) / (Math.abs(theta) + Math.sqrt(theta * theta + 1));
|
|
1135
|
+
const c = 1 / Math.sqrt(t * t + 1);
|
|
1136
|
+
const s = t * c;
|
|
1137
|
+
for (let k = 0; k < n; k++) {
|
|
1138
|
+
const akp = a[k][p];
|
|
1139
|
+
const akq = a[k][q];
|
|
1140
|
+
a[k][p] = c * akp - s * akq;
|
|
1141
|
+
a[k][q] = s * akp + c * akq;
|
|
1142
|
+
}
|
|
1143
|
+
for (let k = 0; k < n; k++) {
|
|
1144
|
+
const apk = a[p][k];
|
|
1145
|
+
const aqk = a[q][k];
|
|
1146
|
+
a[p][k] = c * apk - s * aqk;
|
|
1147
|
+
a[q][k] = s * apk + c * aqk;
|
|
1148
|
+
}
|
|
1149
|
+
for (let k = 0; k < n; k++) {
|
|
1150
|
+
const vkp = v[k][p];
|
|
1151
|
+
const vkq = v[k][q];
|
|
1152
|
+
v[k][p] = c * vkp - s * vkq;
|
|
1153
|
+
v[k][q] = s * vkp + c * vkq;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
const order = Array.from({ length: n }, (_, index) => index).sort((left, right) => a[right][right] - a[left][left] || left - right);
|
|
1158
|
+
return {
|
|
1159
|
+
values: order.map((index) => a[index][index]),
|
|
1160
|
+
vectors: order.map((index) => orient$1(v.map((row) => row[index])))
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
function orient$1(vector) {
|
|
1164
|
+
let pivot = 0;
|
|
1165
|
+
for (let index = 1; index < vector.length; index++) if (Math.abs(vector[index]) > Math.abs(vector[pivot]) + 1e-12) pivot = index;
|
|
1166
|
+
return (vector[pivot] ?? 0) < 0 ? vector.map((value) => value === 0 ? 0 : -value) : vector;
|
|
1167
|
+
}
|
|
1168
|
+
//#endregion
|
|
1169
|
+
//#region src/search/lenses/plateau.ts
|
|
1170
|
+
const PLATEAU_METHOD = `rise of the best improvement over the root across the last \`window\` accepted nodes (screened, no dodged unit, ${INSUFFICIENT_FROM} or more units shared with the root), divided by the best node's standard error sqrt(pooled variance / its shared units); an improvement is the mean per-unit gain over the root on shared units in the objective's direction, and the variance is the between-unit variance of those gains pooled over screened nodes with 2 or more shared units`;
|
|
1171
|
+
/**
|
|
1172
|
+
* The plateau score of a search. Nodes are taken in the view's screened
|
|
1173
|
+
* order, which is registration order. Insufficient with fewer than `window`
|
|
1174
|
+
* accepted nodes, or without a pooled variance above zero.
|
|
1175
|
+
*/
|
|
1176
|
+
function searchPlateau(view, options = {}) {
|
|
1177
|
+
const window = options.window ?? 6;
|
|
1178
|
+
if (!Number.isSafeInteger(window) || window < 1) throw new TypeError(`searchPlateau: window must be a positive integer, got ${String(window)}`);
|
|
1179
|
+
const sign = view.direction === "maximize" ? 1 : -1;
|
|
1180
|
+
const root = new Map(view.unitScores(view.rootNodeId).map((unit) => [unit.unitId, unit.mean]));
|
|
1181
|
+
const accepted = [];
|
|
1182
|
+
let squares = 0;
|
|
1183
|
+
let degreesOfFreedom = 0;
|
|
1184
|
+
for (const nodeId of view.screened) {
|
|
1185
|
+
if (nodeId === view.rootNodeId || !view.complete(nodeId)) continue;
|
|
1186
|
+
const deltas = [];
|
|
1187
|
+
for (const unit of view.unitScores(nodeId)) {
|
|
1188
|
+
const base = root.get(unit.unitId);
|
|
1189
|
+
if (base !== void 0) deltas.push(sign * (unit.mean - base));
|
|
1190
|
+
}
|
|
1191
|
+
if (deltas.length < 2) continue;
|
|
1192
|
+
let total = 0;
|
|
1193
|
+
for (const delta of deltas) total += delta;
|
|
1194
|
+
const mean = total / deltas.length;
|
|
1195
|
+
for (const delta of deltas) squares += (delta - mean) ** 2;
|
|
1196
|
+
degreesOfFreedom += deltas.length - 1;
|
|
1197
|
+
if (deltas.length >= INSUFFICIENT_FROM) accepted.push({
|
|
1198
|
+
nodeId,
|
|
1199
|
+
gain: mean,
|
|
1200
|
+
pairs: deltas.length
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
const pooledVariance = degreesOfFreedom > 0 ? squares / degreesOfFreedom : null;
|
|
1204
|
+
const start = Math.max(0, accepted.length - window);
|
|
1205
|
+
let before = null;
|
|
1206
|
+
for (const entry of accepted.slice(0, start)) if (before === null || entry.gain > before.gain) before = entry;
|
|
1207
|
+
let best = before;
|
|
1208
|
+
for (const entry of accepted.slice(start)) if (best === null || entry.gain > best.gain) best = entry;
|
|
1209
|
+
const base = {
|
|
1210
|
+
window,
|
|
1211
|
+
accepted: accepted.length,
|
|
1212
|
+
windowNodes: accepted.slice(start).map((entry) => entry.nodeId),
|
|
1213
|
+
best: best === null ? null : {
|
|
1214
|
+
...best,
|
|
1215
|
+
gain: round9(best.gain)
|
|
1216
|
+
},
|
|
1217
|
+
pooledVariance: pooledVariance === null ? null : round9(pooledVariance),
|
|
1218
|
+
degreesOfFreedom,
|
|
1219
|
+
method: PLATEAU_METHOD
|
|
1220
|
+
};
|
|
1221
|
+
const insufficient = (reason) => ({
|
|
1222
|
+
...base,
|
|
1223
|
+
value: null,
|
|
1224
|
+
rise: null,
|
|
1225
|
+
noise: null,
|
|
1226
|
+
insufficient: reason
|
|
1227
|
+
});
|
|
1228
|
+
if (accepted.length < window) return insufficient(`${accepted.length} of ${window} accepted nodes (screened, no dodged unit, ${INSUFFICIENT_FROM} or more units shared with the root)`);
|
|
1229
|
+
if (pooledVariance === null || pooledVariance === 0) return insufficient(pooledVariance === null ? "no pooled between-unit variance" : "every per-unit gain equals its node’s mean, so the noise is unmeasured");
|
|
1230
|
+
const leader = best;
|
|
1231
|
+
const rise = Math.max(0, leader.gain) - Math.max(0, before?.gain ?? 0);
|
|
1232
|
+
const noise = Math.sqrt(pooledVariance / leader.pairs);
|
|
1233
|
+
return {
|
|
1234
|
+
...base,
|
|
1235
|
+
value: round9(rise / noise),
|
|
1236
|
+
rise: round9(rise),
|
|
1237
|
+
noise: round9(noise),
|
|
1238
|
+
insufficient: null
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
/**
|
|
1242
|
+
* `base`, except that on a plateau it drafts: it proposes a fresh artifact
|
|
1243
|
+
* (operator `draft`, anchored on the root) instead of refining one. The
|
|
1244
|
+
* plateau is the `landscape` lens's signal (`searchPlateau` on this view):
|
|
1245
|
+
* how many standard errors the best improvement over the root rose across the
|
|
1246
|
+
* last `window` accepted nodes. A draft needs a measured plateau (at least
|
|
1247
|
+
* `window` accepted nodes and a pooled variance) and an expansion count that
|
|
1248
|
+
* is a multiple of `window`, so `base` expands at least `window − 1` times
|
|
1249
|
+
* between drafts and each draft's lineage gets a chance to lift the plateau.
|
|
1250
|
+
* Nodes still screening do not hold a draft back: the kernel expands while
|
|
1251
|
+
* its screening backlog is below twice the lane capacity, so under a full
|
|
1252
|
+
* pipeline some node is always screening, and the plateau reads only nodes
|
|
1253
|
+
* whose screen finished. The leader and patience are `base`'s.
|
|
1254
|
+
*
|
|
1255
|
+
* This is AIDE's draft trigger driven by measured progress instead of a fixed
|
|
1256
|
+
* count; `draftOnPlateau(aide(...))` adds it to `aide`'s own drafts. A draft
|
|
1257
|
+
* pays off only when `base` then expands drafts that trail the leader:
|
|
1258
|
+
* `incumbent` expands only its leader, so a draft that starts below it is
|
|
1259
|
+
* never refined.
|
|
1260
|
+
*/
|
|
1261
|
+
function draftOnPlateau(base, options = {}) {
|
|
1262
|
+
const window = options.window ?? 6;
|
|
1263
|
+
const below = options.below ?? 1;
|
|
1264
|
+
if (!Number.isSafeInteger(window) || window < 1) throw new TypeError(`draftOnPlateau: window must be a positive integer, got ${String(window)}`);
|
|
1265
|
+
if (!(below > 0) || !Number.isFinite(below)) throw new TypeError(`draftOnPlateau: below must be a positive number, got ${String(below)}`);
|
|
1266
|
+
const name = `draft-on-plateau(${base.name},window=${window},below=${below})`;
|
|
1267
|
+
return {
|
|
1268
|
+
name,
|
|
1269
|
+
...base.patience === void 0 ? {} : { patience: base.patience },
|
|
1270
|
+
leader: (view) => base.leader(view),
|
|
1271
|
+
expand(view) {
|
|
1272
|
+
if (view.expansions % window !== 0) return base.expand(view);
|
|
1273
|
+
const plateau = searchPlateau(view, { window });
|
|
1274
|
+
if (plateau.value === null || plateau.value >= below) return base.expand(view);
|
|
1275
|
+
return {
|
|
1276
|
+
parents: [view.rootNodeId],
|
|
1277
|
+
operator: "draft",
|
|
1278
|
+
selection: {
|
|
1279
|
+
rule: name,
|
|
1280
|
+
evidence: {
|
|
1281
|
+
plateau: plateau.value,
|
|
1282
|
+
rise: plateau.rise,
|
|
1283
|
+
noise: plateau.noise,
|
|
1284
|
+
window,
|
|
1285
|
+
accepted: plateau.accepted,
|
|
1286
|
+
expansions: view.expansions
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
//#endregion
|
|
1294
|
+
//#region src/search/lenses/landscape.ts
|
|
1295
|
+
/**
|
|
1296
|
+
* The landscape lens: where each node sits in edit space, and how the score
|
|
1297
|
+
* varies over it.
|
|
1298
|
+
*
|
|
1299
|
+
* Nodes are placed in two dimensions by landmark classical multidimensional
|
|
1300
|
+
* scaling (de Silva and Tenenbaum, 2004) of a pluggable distance between
|
|
1301
|
+
* their profiles. The default distance counts the edits the ledger recorded
|
|
1302
|
+
* between two profiles along their lineage; a caller that can read the
|
|
1303
|
+
* surface blobs passes `surfaceTextEdits`, and a caller with a model
|
|
1304
|
+
* embedding passes `vectorEmbedding`. Every node's score is its improvement
|
|
1305
|
+
* over the root on the units both scored, in the objective's direction, with
|
|
1306
|
+
* the standard error its shared units give it. Kriging (Gaussian-process
|
|
1307
|
+
* regression with each node's own noise) interpolates the scores over a grid
|
|
1308
|
+
* and leaves a cell unknown where the nodes do not constrain it. Basins are
|
|
1309
|
+
* the peaks of the node scores on the nearest-neighbour graph that stand
|
|
1310
|
+
* above their saddle by more than the noise, Bonferroni-bounded over the
|
|
1311
|
+
* graph's local maxima (0-dimensional persistence of the superlevel sets,
|
|
1312
|
+
* ToMATo).
|
|
1313
|
+
*
|
|
1314
|
+
* The signal is `plateau`: how far the best improvement over the root rose
|
|
1315
|
+
* across the last `window` screened nodes, in units of that node's standard
|
|
1316
|
+
* error. Below 1 the search is on a plateau; a policy that drafts afresh when
|
|
1317
|
+
* it is (`draftOnPlateau`) reads the same function on its own view.
|
|
1318
|
+
*/
|
|
1319
|
+
/** The default: edits recorded between two profiles along the lineage. */
|
|
1320
|
+
function lineageEdits() {
|
|
1321
|
+
return {
|
|
1322
|
+
kind: "lineage",
|
|
1323
|
+
name: "lineage-edits",
|
|
1324
|
+
method: "edits between the two profiles along the recorded lineage: the fewest improve, debug or merge edges joining them (a re-proposal edge counts; a draft is written afresh, so its edge is no edit of its anchor)"
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
/** Surfaces whose content digests differ: the edit distance between two
|
|
1328
|
+
* profiles at the granularity of their declared surfaces. */
|
|
1329
|
+
function surfaceDigestEdits() {
|
|
1330
|
+
return {
|
|
1331
|
+
kind: "distance",
|
|
1332
|
+
name: "surface-digest-edits",
|
|
1333
|
+
method: "declared surfaces whose content digests differ (a surface only one node has counts)",
|
|
1334
|
+
distance(a, b) {
|
|
1335
|
+
const left = new Map(a.surfaces.map((surface) => [surface.surfaceId, surface.artifact.sha256]));
|
|
1336
|
+
let count = 0;
|
|
1337
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1338
|
+
for (const surface of b.surfaces) {
|
|
1339
|
+
seen.add(surface.surfaceId);
|
|
1340
|
+
if (left.get(surface.surfaceId) !== surface.artifact.sha256) count += 1;
|
|
1341
|
+
}
|
|
1342
|
+
for (const surfaceId of left.keys()) if (!seen.has(surfaceId)) count += 1;
|
|
1343
|
+
return count;
|
|
1344
|
+
}
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* Line edits between the surfaces' texts: for each declared surface, the
|
|
1349
|
+
* fewest line insertions and deletions that turn one text into the other
|
|
1350
|
+
* (Myers, 1986), summed over surfaces. `read` returns a surface's content:
|
|
1351
|
+
* a string, parsed JSON (flattened with `profileTextLines`), or null when it
|
|
1352
|
+
* cannot be read, which leaves every distance of that node unknown. The lens
|
|
1353
|
+
* stays pure; the caller owns the I/O and its verification.
|
|
1354
|
+
*/
|
|
1355
|
+
function surfaceTextEdits(read) {
|
|
1356
|
+
const lines = /* @__PURE__ */ new Map();
|
|
1357
|
+
const linesOf = (surface) => {
|
|
1358
|
+
const key = surface.artifact.sha256;
|
|
1359
|
+
if (!lines.has(key)) {
|
|
1360
|
+
const content = read(surface);
|
|
1361
|
+
lines.set(key, content === null || content === void 0 ? null : typeof content === "string" ? content.split("\n") : profileTextLines(content));
|
|
1362
|
+
}
|
|
1363
|
+
return lines.get(key);
|
|
1364
|
+
};
|
|
1365
|
+
return {
|
|
1366
|
+
kind: "distance",
|
|
1367
|
+
name: "surface-text-edits",
|
|
1368
|
+
method: "line insertions plus deletions between the surface texts (Myers diff), summed over declared surfaces; JSON is flattened to one line per leaf and per line of a string",
|
|
1369
|
+
distance(a, b) {
|
|
1370
|
+
const right = new Map(b.surfaces.map((surface) => [surface.surfaceId, surface]));
|
|
1371
|
+
let total = 0;
|
|
1372
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1373
|
+
for (const surface of a.surfaces) {
|
|
1374
|
+
seen.add(surface.surfaceId);
|
|
1375
|
+
const own = linesOf(surface);
|
|
1376
|
+
if (own === null) return null;
|
|
1377
|
+
const other = right.get(surface.surfaceId);
|
|
1378
|
+
if (!other) {
|
|
1379
|
+
total += own.length;
|
|
1380
|
+
continue;
|
|
1381
|
+
}
|
|
1382
|
+
if (other.artifact.sha256 === surface.artifact.sha256) continue;
|
|
1383
|
+
const theirs = linesOf(other);
|
|
1384
|
+
if (theirs === null) return null;
|
|
1385
|
+
total += lineEditDistance(own, theirs);
|
|
1386
|
+
}
|
|
1387
|
+
for (const surface of b.surfaces) {
|
|
1388
|
+
if (seen.has(surface.surfaceId)) continue;
|
|
1389
|
+
const theirs = linesOf(surface);
|
|
1390
|
+
if (theirs === null) return null;
|
|
1391
|
+
total += theirs.length;
|
|
1392
|
+
}
|
|
1393
|
+
return total;
|
|
1394
|
+
}
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
/** A caller's vector per node, for example a model embedding of its profile. */
|
|
1398
|
+
function vectorEmbedding(name, vector) {
|
|
1399
|
+
return {
|
|
1400
|
+
kind: "vector",
|
|
1401
|
+
name,
|
|
1402
|
+
method: `Euclidean distance between ${name} vectors`,
|
|
1403
|
+
vector
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* JSON as lines a diff can count: one line per scalar leaf, `path=value`, and
|
|
1408
|
+
* one line per line of a string, so an edit inside a long prompt counts the
|
|
1409
|
+
* lines it changed rather than the whole prompt. Object keys are visited in
|
|
1410
|
+
* code-unit order.
|
|
1411
|
+
*/
|
|
1412
|
+
function profileTextLines(value, path = "$") {
|
|
1413
|
+
if (typeof value === "string") {
|
|
1414
|
+
const parts = value.split("\n");
|
|
1415
|
+
return parts.length === 1 ? [`${path}=${canonicalString(value)}`] : parts.map((line, index) => `${path}[${index}]=${canonicalString(line)}`);
|
|
1416
|
+
}
|
|
1417
|
+
if (Array.isArray(value)) {
|
|
1418
|
+
if (value.length === 0) return [`${path}=[]`];
|
|
1419
|
+
return value.flatMap((entry, index) => profileTextLines(entry, `${path}[${index}]`));
|
|
1420
|
+
}
|
|
1421
|
+
if (value !== null && typeof value === "object") {
|
|
1422
|
+
const keys = Object.keys(value).sort(compareCodeUnits);
|
|
1423
|
+
if (keys.length === 0) return [`${path}={}`];
|
|
1424
|
+
return keys.flatMap((key) => profileTextLines(value[key], `${path}.${key}`));
|
|
1425
|
+
}
|
|
1426
|
+
return [`${path}=${canonicalString(value ?? null)}`];
|
|
1427
|
+
}
|
|
1428
|
+
/** Fewest line insertions plus deletions turning `a` into `b` (Myers' O(ND)
|
|
1429
|
+
* greedy algorithm, which is fast when the texts are close). */
|
|
1430
|
+
function lineEditDistance(a, b) {
|
|
1431
|
+
const n = a.length;
|
|
1432
|
+
const m = b.length;
|
|
1433
|
+
const max = n + m;
|
|
1434
|
+
if (max === 0) return 0;
|
|
1435
|
+
const offset = max;
|
|
1436
|
+
const v = new Int32Array(2 * max + 2);
|
|
1437
|
+
for (let d = 0; d <= max; d++) for (let k = -d; k <= d; k += 2) {
|
|
1438
|
+
let x = k === -d || k !== d && v[offset + k - 1] < v[offset + k + 1] ? v[offset + k + 1] : v[offset + k - 1] + 1;
|
|
1439
|
+
let y = x - k;
|
|
1440
|
+
while (x < n && y < m && a[x] === b[y]) {
|
|
1441
|
+
x += 1;
|
|
1442
|
+
y += 1;
|
|
1443
|
+
}
|
|
1444
|
+
v[offset + k] = x;
|
|
1445
|
+
if (x >= n && y >= m) return d;
|
|
1446
|
+
}
|
|
1447
|
+
return max;
|
|
1448
|
+
}
|
|
1449
|
+
const GRID_METHOD = "Gaussian-process regression (ordinary kriging) of node scores: a constant mean (generalized least squares), a squared-exponential covariance whose prior variance is the between-node variance of the scores minus their mean noise and whose length scale maximizes the marginal likelihood over 0.5, 1, 2 and 4 times the median nearest-neighbour distance, and each node's own noise variance (the pooled between-unit variance over its units shared with the root; the root is the reference at exactly 0); nodes with fewer than 2 shared units are left out; a cell is null where the posterior variance exceeds half the prior variance, so the surface never extends past the nodes that support it; no surface is drawn unless the length scale raises the log-likelihood by 1 or more over independent node scores";
|
|
1450
|
+
/** Scored nodes a surface or a basin count needs: the library's minimum
|
|
1451
|
+
* sample for anything descriptive, as for units. */
|
|
1452
|
+
const SURFACE_MIN_NODES = INSUFFICIENT_FROM;
|
|
1453
|
+
const BASIN_METHOD = "0-dimensional persistence of node scores on the symmetric k-nearest-neighbour graph of placed nodes, read on the leading MDS axes that hold 80% of the positive eigenvalue mass (2 to 8), joined into one component by the shortest edge between components (ToMATo, Chazal et al. 2013): nodes enter from the highest score down, and where two components meet, the lower peak merges into the higher unless it stands above that saddle node by z standard errors of their difference, sqrt(pooled variance / shared units) per node, where z is the standard-normal quantile at 1 − 0.05 / (local maxima − 1), a Bonferroni bound over every peak that could survive, so a flat landscape of noisy nodes counts one basin at least 95% of the time; at most 2000 nodes, evenly spaced by registration plus the 100 best";
|
|
1454
|
+
/** Nodes the basin graph holds: evenly spaced by registration, plus the best. */
|
|
1455
|
+
const BASIN_NODES = 2e3;
|
|
1456
|
+
const BASIN_TOP = 100;
|
|
1457
|
+
/**
|
|
1458
|
+
* The landscape of a search: every node placed by `embed`, the interpolated
|
|
1459
|
+
* score over the placement, the basins of that surface, and the plateau
|
|
1460
|
+
* signal. Pure: it reads the state and calls `embed`.
|
|
1461
|
+
*/
|
|
1462
|
+
function landscape(state, embed = lineageEdits(), options = {}) {
|
|
1463
|
+
const landmarkCap = positiveInteger("landscape", "landmarks", options.landmarks ?? 32);
|
|
1464
|
+
const columns = positiveInteger("landscape", "grid", options.grid ?? 24);
|
|
1465
|
+
const surfaceNodes = positiveInteger("landscape", "surfaceNodes", options.surfaceNodes ?? 400);
|
|
1466
|
+
const window = options.window ?? 6;
|
|
1467
|
+
const header = state.header;
|
|
1468
|
+
const split = options.split ?? rankingSplit(state);
|
|
1469
|
+
const direction = header?.objective.direction ?? "maximize";
|
|
1470
|
+
const plateauEmpty = (reason) => ({
|
|
1471
|
+
value: null,
|
|
1472
|
+
window,
|
|
1473
|
+
accepted: 0,
|
|
1474
|
+
windowNodes: [],
|
|
1475
|
+
rise: null,
|
|
1476
|
+
noise: null,
|
|
1477
|
+
best: null,
|
|
1478
|
+
pooledVariance: null,
|
|
1479
|
+
degreesOfFreedom: 0,
|
|
1480
|
+
method: PLATEAU_METHOD,
|
|
1481
|
+
insufficient: reason
|
|
1482
|
+
});
|
|
1483
|
+
const rootNodeId = header ? state.rootNodeId : null;
|
|
1484
|
+
if (!header || rootNodeId === null) {
|
|
1485
|
+
const reason = header ? "the search has no root node yet" : "the search has not been opened";
|
|
1486
|
+
return result$1(state, emptyData(split, direction, embed, reason, plateauEmpty(reason)));
|
|
1487
|
+
}
|
|
1488
|
+
const nodes = state.nodes();
|
|
1489
|
+
const index = new Map(nodes.map((node, position) => [node.nodeId, position]));
|
|
1490
|
+
const posterior = searchPosterior(state, { split });
|
|
1491
|
+
const scoreOf = new Map(posterior.nodes.map((entry) => [entry.nodeId, entry]));
|
|
1492
|
+
const placement = placeNodes(nodes, index, rootNodeId, embed, landmarkCap, () => lineageAdjacency(state, index));
|
|
1493
|
+
const records = nodes.map((node, position) => {
|
|
1494
|
+
const entry = scoreOf.get(node.nodeId);
|
|
1495
|
+
const firstEdge = node.edgeIds[0] ? state.edge(node.edgeIds[0]) : void 0;
|
|
1496
|
+
const at = placement.coordinates[position];
|
|
1497
|
+
return {
|
|
1498
|
+
nodeId: node.nodeId,
|
|
1499
|
+
ordinal: node.ordinal,
|
|
1500
|
+
depth: node.depth,
|
|
1501
|
+
status: node.status,
|
|
1502
|
+
operator: firstEdge?.operator ?? null,
|
|
1503
|
+
x: at ? round9(at[0]) : null,
|
|
1504
|
+
y: at ? round9(at[1]) : null,
|
|
1505
|
+
unplaced: at ? null : placement.unplaced.get(position) ?? "not placed",
|
|
1506
|
+
score: entry.mean === null || entry.pairs < 2 && node.nodeId !== rootNodeId ? null : round9(entry.mean),
|
|
1507
|
+
standardError: entry.variance === null ? null : round9(Math.sqrt(entry.variance)),
|
|
1508
|
+
pairs: entry.pairs,
|
|
1509
|
+
method: estimateMethodFor(entry.pairs),
|
|
1510
|
+
basin: null
|
|
1511
|
+
};
|
|
1512
|
+
});
|
|
1513
|
+
const lineage = [];
|
|
1514
|
+
for (const node of nodes) {
|
|
1515
|
+
const operator = node.edgeIds[0] ? state.edge(node.edgeIds[0])?.operator ?? "unknown" : "none";
|
|
1516
|
+
for (const parent of node.parents) {
|
|
1517
|
+
if (parent.searchId !== state.searchId) continue;
|
|
1518
|
+
lineage.push({
|
|
1519
|
+
parent: parent.nodeId,
|
|
1520
|
+
child: node.nodeId,
|
|
1521
|
+
operator
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
const scored = records.filter((record) => record.x !== null && record.score !== null && record.pairs >= 2);
|
|
1526
|
+
const gridResult = krige(records, scored, new Map(posterior.nodes.map((entry) => [entry.nodeId, entry.variance])), columns, surfaceNodes);
|
|
1527
|
+
const fullOf = /* @__PURE__ */ new Map();
|
|
1528
|
+
nodes.forEach((node, position) => {
|
|
1529
|
+
const point = placement.full[position];
|
|
1530
|
+
if (point) fullOf.set(node.nodeId, point);
|
|
1531
|
+
});
|
|
1532
|
+
const basins = graphBasins(records, scored, fullOf, {
|
|
1533
|
+
axes: placement.fullAxes,
|
|
1534
|
+
explained: placement.fullExplained
|
|
1535
|
+
}, posterior.pooledVariance, posterior.degreesOfFreedom);
|
|
1536
|
+
const plateau = searchPlateau(searchPolicyView(state, {
|
|
1537
|
+
screened: screenedNodes(state, { maxAttempts: options.maxAttempts }),
|
|
1538
|
+
screening: 0,
|
|
1539
|
+
expansions: 0,
|
|
1540
|
+
...options.maxAttempts === void 0 ? {} : { maxAttempts: options.maxAttempts }
|
|
1541
|
+
}), { window });
|
|
1542
|
+
return result$1(state, {
|
|
1543
|
+
split,
|
|
1544
|
+
direction,
|
|
1545
|
+
embedding: {
|
|
1546
|
+
name: embed.name,
|
|
1547
|
+
method: embed.method,
|
|
1548
|
+
landmarks: placement.landmarks,
|
|
1549
|
+
landmarkDistance: placement.landmarkDistance,
|
|
1550
|
+
eigenvalues: placement.eigenvalues.map(round9),
|
|
1551
|
+
explained: placement.explained === null ? null : round9(placement.explained),
|
|
1552
|
+
negativeMass: placement.negativeMass === null ? null : round9(placement.negativeMass),
|
|
1553
|
+
placed: records.filter((record) => record.x !== null).length,
|
|
1554
|
+
unplaced: records.filter((record) => record.x === null).length
|
|
1555
|
+
},
|
|
1556
|
+
nodes: records,
|
|
1557
|
+
lineage,
|
|
1558
|
+
grid: gridResult.grid,
|
|
1559
|
+
gridInsufficient: gridResult.insufficient,
|
|
1560
|
+
basins,
|
|
1561
|
+
plateau
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
function result$1(state, data) {
|
|
1565
|
+
const { plateau } = data;
|
|
1566
|
+
const signal = {
|
|
1567
|
+
name: "plateau",
|
|
1568
|
+
value: plateau.value,
|
|
1569
|
+
subject: plateau.best?.nodeId ?? null,
|
|
1570
|
+
method: plateau.method,
|
|
1571
|
+
n: plateau.accepted,
|
|
1572
|
+
insufficient: plateau.insufficient
|
|
1573
|
+
};
|
|
1574
|
+
return {
|
|
1575
|
+
lens: "landscape",
|
|
1576
|
+
searchId: state.searchId,
|
|
1577
|
+
sequence: headSequence(state),
|
|
1578
|
+
data,
|
|
1579
|
+
signal
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
function emptyData(split, direction, embed, reason, plateau) {
|
|
1583
|
+
return {
|
|
1584
|
+
split,
|
|
1585
|
+
direction,
|
|
1586
|
+
embedding: {
|
|
1587
|
+
name: embed.name,
|
|
1588
|
+
method: embed.method,
|
|
1589
|
+
landmarks: 0,
|
|
1590
|
+
landmarkDistance: null,
|
|
1591
|
+
eigenvalues: [],
|
|
1592
|
+
explained: null,
|
|
1593
|
+
negativeMass: null,
|
|
1594
|
+
placed: 0,
|
|
1595
|
+
unplaced: 0
|
|
1596
|
+
},
|
|
1597
|
+
nodes: [],
|
|
1598
|
+
lineage: [],
|
|
1599
|
+
grid: null,
|
|
1600
|
+
gridInsufficient: reason,
|
|
1601
|
+
basins: {
|
|
1602
|
+
count: null,
|
|
1603
|
+
neighbours: 0,
|
|
1604
|
+
localMaxima: 0,
|
|
1605
|
+
z: null,
|
|
1606
|
+
graphNodes: 0,
|
|
1607
|
+
graphAxes: 0,
|
|
1608
|
+
graphExplained: null,
|
|
1609
|
+
method: BASIN_METHOD,
|
|
1610
|
+
insufficient: reason,
|
|
1611
|
+
peaks: []
|
|
1612
|
+
},
|
|
1613
|
+
plateau
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
function placeNodes(nodes, index, rootNodeId, embed, landmarkCap, adjacency) {
|
|
1617
|
+
const n = nodes.length;
|
|
1618
|
+
const distancesFrom = distanceRows(nodes, embed, adjacency);
|
|
1619
|
+
const unplaced = /* @__PURE__ */ new Map();
|
|
1620
|
+
const coordinates = new Array(n).fill(void 0);
|
|
1621
|
+
const first = index.get(rootNodeId) ?? 0;
|
|
1622
|
+
const rows = [];
|
|
1623
|
+
const chosen = [];
|
|
1624
|
+
const nearest = new Array(n).fill(Number.POSITIVE_INFINITY);
|
|
1625
|
+
let pick = first;
|
|
1626
|
+
while (pick !== null && chosen.length < landmarkCap) {
|
|
1627
|
+
const row = distancesFrom(pick);
|
|
1628
|
+
rows.push(row);
|
|
1629
|
+
chosen.push(pick);
|
|
1630
|
+
for (let j = 0; j < n; j++) {
|
|
1631
|
+
const d = row[j];
|
|
1632
|
+
if (unplaced.has(j)) continue;
|
|
1633
|
+
if (d === null || d === void 0 || !Number.isFinite(d) || d < 0) {
|
|
1634
|
+
unplaced.set(j, chosen.length === 1 ? `no ${embed.name} distance to the root` : `no ${embed.name} distance to landmark ${nodes[pick].nodeId}`);
|
|
1635
|
+
continue;
|
|
1636
|
+
}
|
|
1637
|
+
nearest[j] = Math.min(nearest[j], d);
|
|
1638
|
+
}
|
|
1639
|
+
if (unplaced.has(first)) break;
|
|
1640
|
+
let next = null;
|
|
1641
|
+
for (let j = 0; j < n; j++) {
|
|
1642
|
+
if (unplaced.has(j) || chosen.includes(j) || nearest[j] <= 0) continue;
|
|
1643
|
+
if (next === null || nearest[j] > nearest[next]) next = j;
|
|
1644
|
+
}
|
|
1645
|
+
pick = next;
|
|
1646
|
+
}
|
|
1647
|
+
const landmarks = chosen.map((node, position) => ({
|
|
1648
|
+
node,
|
|
1649
|
+
row: rows[position]
|
|
1650
|
+
})).filter(({ node }) => !unplaced.has(node));
|
|
1651
|
+
if (landmarks.length === 0) {
|
|
1652
|
+
for (let j = 0; j < n; j++) if (!unplaced.has(j)) unplaced.set(j, `the root has no ${embed.name} placement`);
|
|
1653
|
+
return {
|
|
1654
|
+
coordinates,
|
|
1655
|
+
full: new Array(n).fill(void 0),
|
|
1656
|
+
fullAxes: 0,
|
|
1657
|
+
fullExplained: null,
|
|
1658
|
+
unplaced,
|
|
1659
|
+
landmarks: 0,
|
|
1660
|
+
landmarkDistance: null,
|
|
1661
|
+
eigenvalues: [],
|
|
1662
|
+
explained: null,
|
|
1663
|
+
negativeMass: null
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
const L = landmarks.length;
|
|
1667
|
+
const squared = landmarks.map(({ row: rowA, node: a }) => landmarks.map(({ row: rowB, node: b }) => {
|
|
1668
|
+
const d = ((rowA[b] ?? 0) + (rowB[a] ?? 0)) / 2;
|
|
1669
|
+
return d * d;
|
|
1670
|
+
}));
|
|
1671
|
+
const columnMean = squared[0].map((_, j) => squared.reduce((sum, row) => sum + row[j], 0) / L);
|
|
1672
|
+
const grand = columnMean.reduce((sum, value) => sum + value, 0) / L;
|
|
1673
|
+
const b = squared.map((row, i) => row.map((value, j) => -.5 * (value - columnMean[i] - columnMean[j] + grand)));
|
|
1674
|
+
const between = [];
|
|
1675
|
+
for (let i = 0; i < L; i++) for (let j = i + 1; j < L; j++) between.push(Math.sqrt(squared[i][j]));
|
|
1676
|
+
const { values, vectors } = symmetricEigen(b);
|
|
1677
|
+
const positiveMass = values.filter((value) => value > 1e-12).reduce((sum, value) => sum + value, 0);
|
|
1678
|
+
const negativeMass = values.filter((value) => value < -1e-12).reduce((sum, value) => sum - value, 0);
|
|
1679
|
+
let fullAxes = 2;
|
|
1680
|
+
let held = values.slice(0, 2).reduce((sum, value) => sum + Math.max(value, 0), 0);
|
|
1681
|
+
while (fullAxes < FULL_AXES_MAX && positiveMass > 0 && held / positiveMass < FULL_AXES_MASS && (values[fullAxes] ?? 0) > 1e-12) {
|
|
1682
|
+
held += values[fullAxes];
|
|
1683
|
+
fullAxes += 1;
|
|
1684
|
+
}
|
|
1685
|
+
const allAxes = Array.from({ length: fullAxes }, (_, k) => (values[k] ?? 0) > 1e-12 ? {
|
|
1686
|
+
value: values[k],
|
|
1687
|
+
vector: vectors[k]
|
|
1688
|
+
} : null);
|
|
1689
|
+
const axes = allAxes.slice(0, 2);
|
|
1690
|
+
const full = new Array(n).fill(void 0);
|
|
1691
|
+
for (let j = 0; j < n; j++) {
|
|
1692
|
+
if (unplaced.has(j)) continue;
|
|
1693
|
+
const coordinate = allAxes.map((axis) => {
|
|
1694
|
+
if (axis === null) return 0;
|
|
1695
|
+
let sum = 0;
|
|
1696
|
+
for (let l = 0; l < L; l++) {
|
|
1697
|
+
const d = landmarks[l].row[j];
|
|
1698
|
+
sum += (d * d - columnMean[l]) * axis.vector[l];
|
|
1699
|
+
}
|
|
1700
|
+
return -.5 * sum / Math.sqrt(axis.value);
|
|
1701
|
+
});
|
|
1702
|
+
coordinates[j] = [coordinate[0], coordinate[1]];
|
|
1703
|
+
full[j] = coordinate;
|
|
1704
|
+
}
|
|
1705
|
+
return {
|
|
1706
|
+
coordinates,
|
|
1707
|
+
full,
|
|
1708
|
+
fullAxes,
|
|
1709
|
+
fullExplained: positiveMass > 0 ? Math.min(1, held / positiveMass) : null,
|
|
1710
|
+
unplaced,
|
|
1711
|
+
landmarks: L,
|
|
1712
|
+
landmarkDistance: between.length === 0 ? null : {
|
|
1713
|
+
median: round9(median(between)),
|
|
1714
|
+
max: round9(Math.max(...between))
|
|
1715
|
+
},
|
|
1716
|
+
eigenvalues: values,
|
|
1717
|
+
explained: positiveMass > 0 ? axes.reduce((sum, axis) => sum + (axis?.value ?? 0), 0) / positiveMass : null,
|
|
1718
|
+
negativeMass: positiveMass + negativeMass > 0 ? negativeMass / (positiveMass + negativeMass) : null
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
/**
|
|
1722
|
+
* The undirected lineage graph by node index: an edge joins a child to each
|
|
1723
|
+
* parent in this search when its operator edits a parent (improve, debug,
|
|
1724
|
+
* merge). Every recorded edge counts, re-proposals into an existing node
|
|
1725
|
+
* included, since each says the child is one edit from that parent.
|
|
1726
|
+
*/
|
|
1727
|
+
function lineageAdjacency(state, index) {
|
|
1728
|
+
const neighbours = Array.from({ length: index.size }, () => /* @__PURE__ */ new Set());
|
|
1729
|
+
for (const edge of state.edges()) {
|
|
1730
|
+
if (edge.operator !== "improve" && edge.operator !== "debug" && edge.operator !== "merge") continue;
|
|
1731
|
+
const child = index.get(edge.childNodeId);
|
|
1732
|
+
if (child === void 0) continue;
|
|
1733
|
+
for (const parent of edge.parents) {
|
|
1734
|
+
if (parent.searchId !== state.searchId) continue;
|
|
1735
|
+
const at = index.get(parent.nodeId);
|
|
1736
|
+
if (at === void 0 || at === child) continue;
|
|
1737
|
+
neighbours[child].add(at);
|
|
1738
|
+
neighbours[at].add(child);
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
return neighbours.map((set) => [...set].sort((left, right) => left - right));
|
|
1742
|
+
}
|
|
1743
|
+
/** Distances from one node to every node, by the embedding's kind. */
|
|
1744
|
+
function distanceRows(nodes, embed, adjacency) {
|
|
1745
|
+
const n = nodes.length;
|
|
1746
|
+
if (embed.kind === "lineage") {
|
|
1747
|
+
const neighbours = adjacency();
|
|
1748
|
+
return (from) => {
|
|
1749
|
+
const hops = new Array(n).fill(null);
|
|
1750
|
+
hops[from] = 0;
|
|
1751
|
+
const queue = [from];
|
|
1752
|
+
for (let head = 0; head < queue.length; head++) {
|
|
1753
|
+
const at = queue[head];
|
|
1754
|
+
for (const next of neighbours[at]) {
|
|
1755
|
+
if (hops[next] !== null) continue;
|
|
1756
|
+
hops[next] = hops[at] + 1;
|
|
1757
|
+
queue.push(next);
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
return hops;
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
1763
|
+
if (embed.kind === "vector") {
|
|
1764
|
+
const vectors = nodes.map((node) => embed.vector(node));
|
|
1765
|
+
const size = vectors.find((vector) => vector !== null)?.length ?? 0;
|
|
1766
|
+
return (from) => vectors.map((vector) => {
|
|
1767
|
+
const own = vectors[from];
|
|
1768
|
+
if (!own || !vector || own.length !== size || vector.length !== size) return null;
|
|
1769
|
+
let sum = 0;
|
|
1770
|
+
for (let d = 0; d < size; d++) sum += (own[d] - vector[d]) ** 2;
|
|
1771
|
+
return Math.sqrt(sum);
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1774
|
+
return (from) => nodes.map((node, j) => j === from ? 0 : embed.distance(nodes[from], node));
|
|
1775
|
+
}
|
|
1776
|
+
/**
|
|
1777
|
+
* The score surface by ordinary kriging. Each scored node is a noisy
|
|
1778
|
+
* observation of the surface at its placement, with its own noise variance
|
|
1779
|
+
* (the root, the reference, is exact). The prior variance is the scores'
|
|
1780
|
+
* between-node variance minus their mean noise; when that is not positive the
|
|
1781
|
+
* nodes do not differ beyond noise and there is no surface to draw. The length
|
|
1782
|
+
* scale maximizes the marginal likelihood over a few multiples of the median
|
|
1783
|
+
* nearest-neighbour distance, never below one grid cell.
|
|
1784
|
+
*/
|
|
1785
|
+
function krige(records, scored, variances, columns, surfaceNodes) {
|
|
1786
|
+
if (scored.length < SURFACE_MIN_NODES) return {
|
|
1787
|
+
grid: null,
|
|
1788
|
+
insufficient: `${plural$1(scored.length, "placed node")} share 2 or more units with the root; a surface needs ${SURFACE_MIN_NODES}`
|
|
1789
|
+
};
|
|
1790
|
+
const noiseOf = (node) => variances.get(node.nodeId) ?? null;
|
|
1791
|
+
if (scored.some((node) => noiseOf(node) === null)) return {
|
|
1792
|
+
grid: null,
|
|
1793
|
+
insufficient: "no pooled between-unit variance, so the noise of a node’s score is unknown"
|
|
1794
|
+
};
|
|
1795
|
+
let [x0, x1, y0, y1] = [
|
|
1796
|
+
Number.POSITIVE_INFINITY,
|
|
1797
|
+
Number.NEGATIVE_INFINITY,
|
|
1798
|
+
Number.POSITIVE_INFINITY,
|
|
1799
|
+
Number.NEGATIVE_INFINITY
|
|
1800
|
+
];
|
|
1801
|
+
for (const record of records) {
|
|
1802
|
+
if (record.x === null || record.y === null) continue;
|
|
1803
|
+
x0 = Math.min(x0, record.x);
|
|
1804
|
+
x1 = Math.max(x1, record.x);
|
|
1805
|
+
y0 = Math.min(y0, record.y);
|
|
1806
|
+
y1 = Math.max(y1, record.y);
|
|
1807
|
+
}
|
|
1808
|
+
const span = Math.max(x1 - x0, y1 - y0);
|
|
1809
|
+
if (span <= 0) return {
|
|
1810
|
+
grid: null,
|
|
1811
|
+
insufficient: "every placed node sits at one point"
|
|
1812
|
+
};
|
|
1813
|
+
const pad = span * .05;
|
|
1814
|
+
x0 -= pad;
|
|
1815
|
+
x1 += pad;
|
|
1816
|
+
y0 -= pad;
|
|
1817
|
+
y1 += pad;
|
|
1818
|
+
const byScore = [...scored].sort((left, right) => right.score - left.score || left.ordinal - right.ordinal);
|
|
1819
|
+
const keep = new Set(evenSample(scored, surfaceNodes));
|
|
1820
|
+
const rootNode = scored.find((node) => noiseOf(node) === 0);
|
|
1821
|
+
for (const must of [rootNode, byScore[0]]) {
|
|
1822
|
+
if (!must || keep.has(must)) continue;
|
|
1823
|
+
const drop = [...keep].reverse().find((node) => node !== rootNode && node !== byScore[0]);
|
|
1824
|
+
if (drop) keep.delete(drop);
|
|
1825
|
+
keep.add(must);
|
|
1826
|
+
}
|
|
1827
|
+
const fit = scored.filter((node) => keep.has(node));
|
|
1828
|
+
const n = fit.length;
|
|
1829
|
+
const y = fit.map((node) => node.score);
|
|
1830
|
+
const noise = fit.map((node) => noiseOf(node));
|
|
1831
|
+
const meanScore = y.reduce((sum, value) => sum + value, 0) / n;
|
|
1832
|
+
const spread = y.reduce((sum, value) => sum + (value - meanScore) ** 2, 0) / (n - 1);
|
|
1833
|
+
const meanNoise = noise.reduce((sum, value) => sum + value, 0) / n;
|
|
1834
|
+
const priorVariance = spread - meanNoise;
|
|
1835
|
+
if (!(priorVariance > 0)) return {
|
|
1836
|
+
grid: null,
|
|
1837
|
+
insufficient: `the nodes' scores vary no more than their noise (between-node variance ${fixed(spread)}, mean noise variance ${fixed(meanNoise)}), so there is no surface beyond a flat one`
|
|
1838
|
+
};
|
|
1839
|
+
const cell = Math.max(x1 - x0, y1 - y0) / columns;
|
|
1840
|
+
const nearest = evenSample(fit, 2e3).map((a, _, sample) => {
|
|
1841
|
+
let best = Number.POSITIVE_INFINITY;
|
|
1842
|
+
for (const b of sample) {
|
|
1843
|
+
if (a === b) continue;
|
|
1844
|
+
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
|
1845
|
+
if (d > 0 && d < best) best = d;
|
|
1846
|
+
}
|
|
1847
|
+
return best;
|
|
1848
|
+
});
|
|
1849
|
+
const typical = Math.max(median(nearest.filter(Number.isFinite)) ?? cell, cell);
|
|
1850
|
+
const d2 = fit.map((a) => fit.map((b) => (a.x - b.x) ** 2 + (a.y - b.y) ** 2));
|
|
1851
|
+
const jitter = priorVariance * 1e-9;
|
|
1852
|
+
let best = null;
|
|
1853
|
+
for (const multiple of [
|
|
1854
|
+
.5,
|
|
1855
|
+
1,
|
|
1856
|
+
2,
|
|
1857
|
+
4
|
|
1858
|
+
]) {
|
|
1859
|
+
const lengthScale = Math.max(typical * multiple, cell);
|
|
1860
|
+
const lower = cholesky(d2.map((row, i) => row.map((value, j) => priorVariance * Math.exp(-value / (2 * lengthScale * lengthScale)) + (i === j ? noise[i] + jitter : 0))));
|
|
1861
|
+
if (lower === null) continue;
|
|
1862
|
+
const ones = forward(lower, new Array(n).fill(1));
|
|
1863
|
+
const whiteY = forward(lower, y);
|
|
1864
|
+
let numerator = 0;
|
|
1865
|
+
let denominator = 0;
|
|
1866
|
+
for (let i = 0; i < n; i++) {
|
|
1867
|
+
numerator += ones[i] * whiteY[i];
|
|
1868
|
+
denominator += ones[i] * ones[i];
|
|
1869
|
+
}
|
|
1870
|
+
const mean = numerator / denominator;
|
|
1871
|
+
const residual = forward(lower, y.map((value) => value - mean));
|
|
1872
|
+
let quadratic = 0;
|
|
1873
|
+
let logDeterminant = 0;
|
|
1874
|
+
for (let i = 0; i < n; i++) {
|
|
1875
|
+
quadratic += residual[i] ** 2;
|
|
1876
|
+
logDeterminant += 2 * Math.log(lower[i][i]);
|
|
1877
|
+
}
|
|
1878
|
+
const logLikelihood = -.5 * (quadratic + logDeterminant + n * Math.log(2 * Math.PI));
|
|
1879
|
+
if (best === null || logLikelihood > best.logLikelihood) best = {
|
|
1880
|
+
lengthScale,
|
|
1881
|
+
lower,
|
|
1882
|
+
alpha: backward(lower, residual),
|
|
1883
|
+
mean,
|
|
1884
|
+
logLikelihood
|
|
1885
|
+
};
|
|
1886
|
+
}
|
|
1887
|
+
if (best === null) return {
|
|
1888
|
+
grid: null,
|
|
1889
|
+
insufficient: "the kriging covariance is not positive definite"
|
|
1890
|
+
};
|
|
1891
|
+
const independent = independentLogLikelihood(y, noise, priorVariance);
|
|
1892
|
+
if (best.logLikelihood - independent < 1) return {
|
|
1893
|
+
grid: null,
|
|
1894
|
+
insufficient: `node scores do not correlate with distance in this embedding (log-likelihood ${fixed(best.logLikelihood)} with a length scale against ${fixed(independent)} without one), so a surface would only interpolate noise`
|
|
1895
|
+
};
|
|
1896
|
+
const { lengthScale, lower, alpha, mean } = best;
|
|
1897
|
+
const rows = columns;
|
|
1898
|
+
const values = [];
|
|
1899
|
+
const sd = [];
|
|
1900
|
+
let supported = 0;
|
|
1901
|
+
for (let r = 0; r < rows; r++) {
|
|
1902
|
+
const row = [];
|
|
1903
|
+
const sdRow = [];
|
|
1904
|
+
const gy = y0 + (r + .5) * (y1 - y0) / rows;
|
|
1905
|
+
for (let c = 0; c < columns; c++) {
|
|
1906
|
+
const gx = x0 + (c + .5) * (x1 - x0) / columns;
|
|
1907
|
+
const k = fit.map((node) => priorVariance * Math.exp(-((node.x - gx) ** 2 + (node.y - gy) ** 2) / (2 * lengthScale * lengthScale)));
|
|
1908
|
+
let value = mean;
|
|
1909
|
+
for (let i = 0; i < n; i++) value += k[i] * alpha[i];
|
|
1910
|
+
const white = forward(lower, k);
|
|
1911
|
+
let explained = 0;
|
|
1912
|
+
for (const entry of white) explained += entry * entry;
|
|
1913
|
+
const variance = Math.max(0, priorVariance - explained);
|
|
1914
|
+
sdRow.push(round9(Math.sqrt(variance)));
|
|
1915
|
+
if (variance <= priorVariance / 2) {
|
|
1916
|
+
row.push(round9(value));
|
|
1917
|
+
supported += 1;
|
|
1918
|
+
} else row.push(null);
|
|
1919
|
+
}
|
|
1920
|
+
values.push(row);
|
|
1921
|
+
sd.push(sdRow);
|
|
1922
|
+
}
|
|
1923
|
+
return {
|
|
1924
|
+
grid: {
|
|
1925
|
+
columns,
|
|
1926
|
+
rows,
|
|
1927
|
+
x: [round9(x0), round9(x1)],
|
|
1928
|
+
y: [round9(y0), round9(y1)],
|
|
1929
|
+
values,
|
|
1930
|
+
sd,
|
|
1931
|
+
supportedCells: supported,
|
|
1932
|
+
model: {
|
|
1933
|
+
mean: round9(mean),
|
|
1934
|
+
priorVariance: round9(priorVariance),
|
|
1935
|
+
lengthScale: round9(lengthScale),
|
|
1936
|
+
logLikelihood: round9(best.logLikelihood),
|
|
1937
|
+
independentLogLikelihood: round9(independent),
|
|
1938
|
+
fitNodes: n
|
|
1939
|
+
},
|
|
1940
|
+
method: GRID_METHOD
|
|
1941
|
+
},
|
|
1942
|
+
insufficient: null
|
|
1943
|
+
};
|
|
1944
|
+
}
|
|
1945
|
+
/** Log likelihood of scores drawn independently around a generalized
|
|
1946
|
+
* least-squares mean, each with the prior variance plus its own noise. */
|
|
1947
|
+
function independentLogLikelihood(y, noise, priorVariance) {
|
|
1948
|
+
let numerator = 0;
|
|
1949
|
+
let denominator = 0;
|
|
1950
|
+
for (let i = 0; i < y.length; i++) {
|
|
1951
|
+
const variance = priorVariance + noise[i];
|
|
1952
|
+
numerator += y[i] / variance;
|
|
1953
|
+
denominator += 1 / variance;
|
|
1954
|
+
}
|
|
1955
|
+
const mean = numerator / denominator;
|
|
1956
|
+
let total = 0;
|
|
1957
|
+
for (let i = 0; i < y.length; i++) {
|
|
1958
|
+
const variance = priorVariance + noise[i];
|
|
1959
|
+
total += -.5 * ((y[i] - mean) ** 2 / variance + Math.log(2 * Math.PI * variance));
|
|
1960
|
+
}
|
|
1961
|
+
return total;
|
|
1962
|
+
}
|
|
1963
|
+
/** Solves L z = b for lower-triangular L. */
|
|
1964
|
+
function forward(lower, b) {
|
|
1965
|
+
const n = lower.length;
|
|
1966
|
+
const z = new Array(n).fill(0);
|
|
1967
|
+
for (let i = 0; i < n; i++) {
|
|
1968
|
+
const row = lower[i];
|
|
1969
|
+
let value = b[i];
|
|
1970
|
+
for (let k = 0; k < i; k++) value -= row[k] * z[k];
|
|
1971
|
+
z[i] = value / row[i];
|
|
1972
|
+
}
|
|
1973
|
+
return z;
|
|
1974
|
+
}
|
|
1975
|
+
/** Solves Lᵀ x = z for lower-triangular L. */
|
|
1976
|
+
function backward(lower, z) {
|
|
1977
|
+
const n = lower.length;
|
|
1978
|
+
const x = new Array(n).fill(0);
|
|
1979
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
1980
|
+
let value = z[i];
|
|
1981
|
+
for (let k = i + 1; k < n; k++) value -= lower[k][i] * x[k];
|
|
1982
|
+
x[i] = value / lower[i][i];
|
|
1983
|
+
}
|
|
1984
|
+
return x;
|
|
1985
|
+
}
|
|
1986
|
+
function cellOf(grid, x, y) {
|
|
1987
|
+
const c = Math.min(grid.columns - 1, Math.max(0, Math.floor((x - grid.x[0]) / (grid.x[1] - grid.x[0]) * grid.columns)));
|
|
1988
|
+
const r = Math.min(grid.rows - 1, Math.max(0, Math.floor((y - grid.y[0]) / (grid.y[1] - grid.y[0]) * grid.rows)));
|
|
1989
|
+
return grid.values[r][c] === null ? null : r * grid.columns + c;
|
|
1990
|
+
}
|
|
1991
|
+
/**
|
|
1992
|
+
* Basins on the k-nearest-neighbour graph of the scored, placed nodes, by
|
|
1993
|
+
* ToMATo's sweep. Nodes enter from the highest score down (earlier
|
|
1994
|
+
* registration first on a tie). A node with no entered neighbour starts a
|
|
1995
|
+
* component, of which it is the peak; otherwise it joins its highest entered
|
|
1996
|
+
* neighbour's component. Where it touches another component, the one with
|
|
1997
|
+
* the lower peak merges into the higher unless its peak stands above this
|
|
1998
|
+
* node, the saddle, by two standard errors of their difference; a component
|
|
1999
|
+
* that survives its first saddle keeps that persistence. Every component left
|
|
2000
|
+
* at the end is a basin, labelled by its peak.
|
|
2001
|
+
*/
|
|
2002
|
+
function graphBasins(records, scoredNodes, fullOf, space, pooledVariance, degreesOfFreedom) {
|
|
2003
|
+
const empty = (reason) => ({
|
|
2004
|
+
count: null,
|
|
2005
|
+
neighbours: 0,
|
|
2006
|
+
localMaxima: 0,
|
|
2007
|
+
z: null,
|
|
2008
|
+
graphNodes: 0,
|
|
2009
|
+
graphAxes: 0,
|
|
2010
|
+
graphExplained: null,
|
|
2011
|
+
method: BASIN_METHOD,
|
|
2012
|
+
insufficient: reason,
|
|
2013
|
+
peaks: []
|
|
2014
|
+
});
|
|
2015
|
+
let scored = scoredNodes;
|
|
2016
|
+
if (scored.length < SURFACE_MIN_NODES) return empty(`${plural$1(scored.length, "placed node")} share 2 or more units with the root; basins need ${SURFACE_MIN_NODES}`);
|
|
2017
|
+
if (pooledVariance === null || pooledVariance <= 0) return empty("no pooled between-unit variance, so the noise a peak must clear is unknown");
|
|
2018
|
+
const sample = new Set(evenSample(scored, BASIN_NODES));
|
|
2019
|
+
for (const node of [...scored].sort((left, right) => right.score - left.score || left.ordinal - right.ordinal).slice(0, BASIN_TOP)) sample.add(node);
|
|
2020
|
+
scored = scoredNodes.filter((node) => sample.has(node));
|
|
2021
|
+
const k = Math.min(BASIN_NEIGHBOURS, scored.length - 1);
|
|
2022
|
+
const points = scored.map((node) => fullOf.get(node.nodeId) ?? [node.x, node.y]);
|
|
2023
|
+
const adjacency = connectComponents(points, nearestNeighbours(points, k));
|
|
2024
|
+
const variance = (index) => pooledVariance / scored[index].pairs;
|
|
2025
|
+
const order = scored.map((_, index) => index).sort((left, right) => scored[right].score - scored[left].score || scored[left].ordinal - scored[right].ordinal);
|
|
2026
|
+
const rank = new Int32Array(scored.length);
|
|
2027
|
+
order.forEach((index, position) => {
|
|
2028
|
+
rank[index] = position;
|
|
2029
|
+
});
|
|
2030
|
+
const parent = new Int32Array(scored.length).fill(-1);
|
|
2031
|
+
const find = (index) => {
|
|
2032
|
+
let root = index;
|
|
2033
|
+
while (parent[root] !== root) root = parent[root];
|
|
2034
|
+
let at = index;
|
|
2035
|
+
while (parent[at] !== root) {
|
|
2036
|
+
const next = parent[at];
|
|
2037
|
+
parent[at] = root;
|
|
2038
|
+
at = next;
|
|
2039
|
+
}
|
|
2040
|
+
return root;
|
|
2041
|
+
};
|
|
2042
|
+
let maxima = 0;
|
|
2043
|
+
for (let index = 0; index < scored.length; index++) if (adjacency[index].every((neighbour) => rank[neighbour] > rank[index])) maxima += 1;
|
|
2044
|
+
const z = zQuantile(1 - BASIN_ALPHA / Math.max(1, maxima - 1));
|
|
2045
|
+
const survived = /* @__PURE__ */ new Map();
|
|
2046
|
+
for (const index of order) {
|
|
2047
|
+
let highest = -1;
|
|
2048
|
+
for (const neighbour of adjacency[index]) {
|
|
2049
|
+
if (parent[neighbour] === -1) continue;
|
|
2050
|
+
if (highest === -1 || rank[neighbour] < rank[highest]) highest = neighbour;
|
|
2051
|
+
}
|
|
2052
|
+
if (highest === -1) {
|
|
2053
|
+
parent[index] = index;
|
|
2054
|
+
continue;
|
|
2055
|
+
}
|
|
2056
|
+
parent[index] = find(highest);
|
|
2057
|
+
for (const neighbour of adjacency[index]) {
|
|
2058
|
+
if (parent[neighbour] === -1) continue;
|
|
2059
|
+
const mine = find(index);
|
|
2060
|
+
const theirs = find(neighbour);
|
|
2061
|
+
if (mine === theirs) continue;
|
|
2062
|
+
const [high, low] = rank[mine] < rank[theirs] ? [mine, theirs] : [theirs, mine];
|
|
2063
|
+
const persistence = scored[low].score - scored[index].score;
|
|
2064
|
+
const threshold = z * Math.sqrt(variance(low) + variance(index));
|
|
2065
|
+
if (persistence < threshold) parent[low] = high;
|
|
2066
|
+
else if (!survived.has(low)) survived.set(low, {
|
|
2067
|
+
saddle: index,
|
|
2068
|
+
persistence,
|
|
2069
|
+
threshold
|
|
2070
|
+
});
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
const peaks = [...new Set(order.map(find))].sort((left, right) => rank[left] - rank[right]);
|
|
2074
|
+
const basinOf = new Map(peaks.map((peak, basin) => [peak, basin]));
|
|
2075
|
+
const members = new Array(peaks.length).fill(0);
|
|
2076
|
+
const byNode = /* @__PURE__ */ new Map();
|
|
2077
|
+
scored.forEach((node, index) => {
|
|
2078
|
+
const basin = basinOf.get(find(index));
|
|
2079
|
+
byNode.set(node.nodeId, basin);
|
|
2080
|
+
members[basin] += 1;
|
|
2081
|
+
});
|
|
2082
|
+
for (const record of records) record.basin = byNode.get(record.nodeId) ?? null;
|
|
2083
|
+
return {
|
|
2084
|
+
count: peaks.length,
|
|
2085
|
+
neighbours: k,
|
|
2086
|
+
localMaxima: maxima,
|
|
2087
|
+
z: round9(z),
|
|
2088
|
+
graphNodes: scored.length,
|
|
2089
|
+
graphAxes: space.axes,
|
|
2090
|
+
graphExplained: space.explained === null ? null : round9(space.explained),
|
|
2091
|
+
method: `${BASIN_METHOD}; pooled over ${degreesOfFreedom} degrees of freedom`,
|
|
2092
|
+
insufficient: null,
|
|
2093
|
+
peaks: peaks.map((peak, basin) => {
|
|
2094
|
+
const node = scored[peak];
|
|
2095
|
+
const own = survived.get(peak) ?? null;
|
|
2096
|
+
return {
|
|
2097
|
+
peak: {
|
|
2098
|
+
nodeId: node.nodeId,
|
|
2099
|
+
x: node.x,
|
|
2100
|
+
y: node.y,
|
|
2101
|
+
score: node.score,
|
|
2102
|
+
pairs: node.pairs
|
|
2103
|
+
},
|
|
2104
|
+
persistence: own === null ? null : round9(own.persistence),
|
|
2105
|
+
threshold: own === null ? null : round9(own.threshold),
|
|
2106
|
+
saddle: own === null ? null : scored[own.saddle].nodeId,
|
|
2107
|
+
nodes: members[basin]
|
|
2108
|
+
};
|
|
2109
|
+
})
|
|
2110
|
+
};
|
|
2111
|
+
}
|
|
2112
|
+
const BASIN_NEIGHBOURS = 6;
|
|
2113
|
+
/** The basin graph's space: leading MDS axes up to this share of the positive
|
|
2114
|
+
* eigenvalue mass, and at most this many. */
|
|
2115
|
+
const FULL_AXES_MASS = .8;
|
|
2116
|
+
const FULL_AXES_MAX = 8;
|
|
2117
|
+
/** Family-wise rate of a spurious basin on a flat landscape. */
|
|
2118
|
+
const BASIN_ALPHA = .05;
|
|
2119
|
+
/**
|
|
2120
|
+
* Joins a graph's components into one (Borůvka): each round links every
|
|
2121
|
+
* component to its nearest point in another component by one edge, until
|
|
2122
|
+
* one component remains. Without it, two regions the k-nearest-neighbour
|
|
2123
|
+
* graph leaves apart would each count as a basin with no saddle to test.
|
|
2124
|
+
*/
|
|
2125
|
+
function connectComponents(points, adjacency) {
|
|
2126
|
+
const n = points.length;
|
|
2127
|
+
const parent = Int32Array.from({ length: n }, (_, index) => index);
|
|
2128
|
+
const find = (index) => {
|
|
2129
|
+
let root = index;
|
|
2130
|
+
while (parent[root] !== root) root = parent[root];
|
|
2131
|
+
while (parent[index] !== root) {
|
|
2132
|
+
const next = parent[index];
|
|
2133
|
+
parent[index] = root;
|
|
2134
|
+
index = next;
|
|
2135
|
+
}
|
|
2136
|
+
return root;
|
|
2137
|
+
};
|
|
2138
|
+
for (let a = 0; a < n; a++) for (const b of adjacency[a]) parent[find(a)] = find(b);
|
|
2139
|
+
const edges = adjacency.map((list) => new Set(list));
|
|
2140
|
+
for (;;) {
|
|
2141
|
+
const component = Int32Array.from({ length: n }, (_, index) => find(index));
|
|
2142
|
+
const best = /* @__PURE__ */ new Map();
|
|
2143
|
+
for (let a = 0; a < n; a++) {
|
|
2144
|
+
const own = component[a];
|
|
2145
|
+
for (let b = 0; b < n; b++) {
|
|
2146
|
+
if (component[b] === own) continue;
|
|
2147
|
+
const distance = euclidean(points[a], points[b]);
|
|
2148
|
+
const known = best.get(own);
|
|
2149
|
+
if (!known || distance < known.distance) best.set(own, {
|
|
2150
|
+
a,
|
|
2151
|
+
b,
|
|
2152
|
+
distance
|
|
2153
|
+
});
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
if (best.size === 0) break;
|
|
2157
|
+
for (const { a, b } of best.values()) {
|
|
2158
|
+
edges[a].add(b);
|
|
2159
|
+
edges[b].add(a);
|
|
2160
|
+
parent[find(a)] = find(b);
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
return edges.map((set) => [...set].sort((left, right) => left - right));
|
|
2164
|
+
}
|
|
2165
|
+
/**
|
|
2166
|
+
* The symmetric k-nearest-neighbour graph of points in any dimension: each
|
|
2167
|
+
* point joins its k nearest (ties by index) and every edge is kept in both
|
|
2168
|
+
* directions. Brute force, so callers keep n in the low thousands.
|
|
2169
|
+
*/
|
|
2170
|
+
function nearestNeighbours(points, k) {
|
|
2171
|
+
const n = points.length;
|
|
2172
|
+
const adjacency = Array.from({ length: n }, () => /* @__PURE__ */ new Set());
|
|
2173
|
+
if (n < 2 || k < 1) return adjacency.map(() => []);
|
|
2174
|
+
for (let index = 0; index < n; index++) {
|
|
2175
|
+
const best = [];
|
|
2176
|
+
for (let other = 0; other < n; other++) {
|
|
2177
|
+
if (other === index) continue;
|
|
2178
|
+
const distance = euclidean(points[index], points[other]);
|
|
2179
|
+
if (best.length === k && distance >= best[k - 1].distance) continue;
|
|
2180
|
+
best.push({
|
|
2181
|
+
index: other,
|
|
2182
|
+
distance
|
|
2183
|
+
});
|
|
2184
|
+
best.sort((left, right) => left.distance - right.distance || left.index - right.index);
|
|
2185
|
+
if (best.length > k) best.length = k;
|
|
2186
|
+
}
|
|
2187
|
+
for (const { index: other } of best) {
|
|
2188
|
+
adjacency[index].add(other);
|
|
2189
|
+
adjacency[other].add(index);
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
return adjacency.map((set) => [...set].sort((left, right) => left - right));
|
|
2193
|
+
}
|
|
2194
|
+
function euclidean(left, right) {
|
|
2195
|
+
let sum = 0;
|
|
2196
|
+
for (let d = 0; d < left.length; d++) sum += (left[d] - (right[d] ?? 0)) ** 2;
|
|
2197
|
+
return Math.sqrt(sum);
|
|
2198
|
+
}
|
|
2199
|
+
const SHADES = " .:-=+*#%@";
|
|
2200
|
+
/** The landscape as text for a terminal or a proposer's context: the same
|
|
2201
|
+
* numbers the view draws, with a coarse shaded map. */
|
|
2202
|
+
function formatLandscape(lens) {
|
|
2203
|
+
const { data } = lens;
|
|
2204
|
+
const lines = [];
|
|
2205
|
+
const { embedding } = data;
|
|
2206
|
+
lines.push(`Landscape (${data.split} split, larger is better; ${embedding.name}: ${embedding.method})`);
|
|
2207
|
+
lines.push(` placed ${embedding.placed} of ${embedding.placed + embedding.unplaced} nodes by landmark MDS on ${plural$1(embedding.landmarks, "landmark")}${embedding.landmarkDistance ? ` (landmarks lie a median ${fixed(embedding.landmarkDistance.median)} and at most ${fixed(embedding.landmarkDistance.max)} apart)` : ""}; two axes hold ${percent(embedding.explained)} of the positive eigenvalue mass (negative mass ${percent(embedding.negativeMass)})`);
|
|
2208
|
+
const reasons = /* @__PURE__ */ new Map();
|
|
2209
|
+
for (const node of data.nodes) if (node.unplaced) reasons.set(node.unplaced, (reasons.get(node.unplaced) ?? 0) + 1);
|
|
2210
|
+
for (const [reason, count] of reasons) lines.push(` unplaced ${count}: ${reason}`);
|
|
2211
|
+
const methods = /* @__PURE__ */ new Map();
|
|
2212
|
+
for (const node of data.nodes) methods.set(node.method, (methods.get(node.method) ?? 0) + 1);
|
|
2213
|
+
lines.push(` scores (improvement over the root on shared units): ${[
|
|
2214
|
+
"bootstrap",
|
|
2215
|
+
"descriptive",
|
|
2216
|
+
"insufficient",
|
|
2217
|
+
"none"
|
|
2218
|
+
].map((method) => `${methods.get(method) ?? 0} ${method}`).join(", ")}`);
|
|
2219
|
+
if (data.grid === null) lines.push(` surface: insufficient: ${data.gridInsufficient}`);
|
|
2220
|
+
else {
|
|
2221
|
+
const { grid } = data;
|
|
2222
|
+
lines.push(` surface: ${grid.columns}×${grid.rows} grid kriged from ${plural$1(grid.model.fitNodes, "node")} (mean ${signed(grid.model.mean)}, prior sd ${fixed(Math.sqrt(grid.model.priorVariance))}, length scale ${fixed(grid.model.lengthScale)}); ${grid.supportedCells} of ${grid.columns * grid.rows} cells supported (posterior variance at most half the prior)`);
|
|
2223
|
+
}
|
|
2224
|
+
const { basins } = data;
|
|
2225
|
+
if (basins.count === null) lines.push(` basins: insufficient: ${basins.insufficient}`);
|
|
2226
|
+
else {
|
|
2227
|
+
lines.push(` basins: ${basins.count} on the ${basins.neighbours}-nearest-neighbour graph of ${plural$1(basins.graphNodes, "node")} in ${basins.graphAxes} MDS axes holding ${percent(basins.graphExplained)} of the distance structure (a lower peak counts when it stands ${fixed(basins.z)} standard errors of the difference above its saddle, Bonferroni over ${basins.localMaxima} local ${basins.localMaxima === 1 ? "maximum" : "maxima"} at 5%)`);
|
|
2228
|
+
basins.peaks.forEach((basin, index) => {
|
|
2229
|
+
const standing = basin.persistence === null ? index === 0 ? "the highest" : "a separate region of the graph" : `persistence ${fixed(basin.persistence)} ≥ ${fixed(basin.threshold)} over saddle ${basin.saddle}`;
|
|
2230
|
+
lines.push(` ${index + 1}. ${basin.peak.nodeId} ${signed(basin.peak.score)} on ${basin.peak.pairs} units at (${fixed(basin.peak.x)}, ${fixed(basin.peak.y)}), ${standing}; ${plural$1(basin.nodes, "node")}`);
|
|
2231
|
+
});
|
|
2232
|
+
}
|
|
2233
|
+
const { plateau } = data;
|
|
2234
|
+
if (plateau.value === null) lines.push(` plateau: insufficient: ${plateau.insufficient}`);
|
|
2235
|
+
else {
|
|
2236
|
+
const verdict = plateau.value < 1 ? "on a plateau" : "still climbing";
|
|
2237
|
+
lines.push(` plateau: ${fixed(plateau.value)}: the best improvement over the root rose ${signed(plateau.rise)} across the last ${plateau.window} of ${plateau.accepted} accepted nodes, one standard error ${fixed(plateau.noise)} (best ${plateau.best.nodeId} on ${plateau.best.pairs} units, ${plateau.degreesOfFreedom} pooled df): ${verdict}`);
|
|
2238
|
+
}
|
|
2239
|
+
if (data.grid !== null) {
|
|
2240
|
+
const { grid } = data;
|
|
2241
|
+
const present = grid.values.flat().filter((value) => value !== null);
|
|
2242
|
+
const low = Math.min(...present);
|
|
2243
|
+
const high = Math.max(...present);
|
|
2244
|
+
const marks = /* @__PURE__ */ new Map();
|
|
2245
|
+
basins.peaks.forEach((basin, index) => {
|
|
2246
|
+
const cell = cellOf(grid, basin.peak.x, basin.peak.y);
|
|
2247
|
+
if (cell !== null) marks.set(cell, String((index + 1) % 10));
|
|
2248
|
+
});
|
|
2249
|
+
lines.push(` map (top row is the highest y; shade ${JSON.stringify(SHADES)} runs from ${signed(low)} to ${signed(high)}; digits mark each basin's peak node):`);
|
|
2250
|
+
for (let r = grid.rows - 1; r >= 0; r--) {
|
|
2251
|
+
let row = "";
|
|
2252
|
+
for (let c = 0; c < grid.columns; c++) {
|
|
2253
|
+
const mark = marks.get(r * grid.columns + c);
|
|
2254
|
+
const value = grid.values[r][c];
|
|
2255
|
+
if (mark) row += mark;
|
|
2256
|
+
else if (value === null || value === void 0) row += " ";
|
|
2257
|
+
else {
|
|
2258
|
+
const level = high > low ? (value - low) / (high - low) : .5;
|
|
2259
|
+
row += SHADES[Math.min(9, Math.floor(level * 10))];
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
lines.push(` |${row}|`);
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
return lines.join("\n");
|
|
2266
|
+
}
|
|
2267
|
+
function percent(value) {
|
|
2268
|
+
return value === null ? "unknown" : `${Math.round(value * 1e3) / 10}%`;
|
|
2269
|
+
}
|
|
2270
|
+
function fixed(value) {
|
|
2271
|
+
return value.toFixed(3);
|
|
2272
|
+
}
|
|
2273
|
+
function signed(value) {
|
|
2274
|
+
return `${value >= 0 ? "+" : ""}${value.toFixed(3)}`;
|
|
2275
|
+
}
|
|
2276
|
+
//#endregion
|
|
2277
|
+
//#region src/search/lenses/meta-search.ts
|
|
2278
|
+
/**
|
|
2279
|
+
* `metaSearch(searches)`: a search of searches (search-tree-design §12).
|
|
2280
|
+
*
|
|
2281
|
+
* Every search is one node. Its genome is the configuration that ran it: the
|
|
2282
|
+
* expansion policy and allocator its header declares (their names carry
|
|
2283
|
+
* their parameters, such as patience, operator weights and rung sizes), its
|
|
2284
|
+
* budget, and the proposer its edges and candidate-generation operations
|
|
2285
|
+
* name, with the proposer's model. Its score is the held-out lift its claim
|
|
2286
|
+
* measured, per known dollar of the whole search.
|
|
2287
|
+
*
|
|
2288
|
+
* The held-out lift is `estimateNode` of one node against the root on the
|
|
2289
|
+
* sealed test split, from the test cells themselves: the shipped node on
|
|
2290
|
+
* `ship`; on `hold`, the first finalist not decided invalid, in the claim's
|
|
2291
|
+
* order, which the claim fixed from selection data before any test cell ran.
|
|
2292
|
+
* A hold's measured lift counts rather than being dropped: keeping only the
|
|
2293
|
+
* searches whose test was significant would inflate a configuration's mean.
|
|
2294
|
+
* A search whose claim is open, missing, `test-cannot-resolve`, contradicted
|
|
2295
|
+
* by its own ledger (`verifySearchClaim`), held with no finalist tested, or
|
|
2296
|
+
* whose lift pairs fewer than 2 test units is unscored with its reason, never
|
|
2297
|
+
* 0, and every configuration reports how many of its searches were scored.
|
|
2298
|
+
*
|
|
2299
|
+
* Dollars are the search's committed spend: every cell and operation, the
|
|
2300
|
+
* claim's test cells included. An unknown-cost cell or operation leaves only a
|
|
2301
|
+
* floor, so that search's lift per dollar is a bound, not a value, and stays
|
|
2302
|
+
* out of every estimate. A search that recorded no spend has none either.
|
|
2303
|
+
*
|
|
2304
|
+
* Searches arrange into a forest by derivation (`derivedFrom`: a search that
|
|
2305
|
+
* started from a node of another) and containment (`containment`: a search
|
|
2306
|
+
* that ran inside a cell of another). A search contained in an outer search's
|
|
2307
|
+
* cell belongs to that cell's node: the outer node is its configuration, and
|
|
2308
|
+
* the outer node's lineage is the configuration's lineage.
|
|
2309
|
+
*
|
|
2310
|
+
* Configurations group searches with one genome and one objective (subject,
|
|
2311
|
+
* metric, direction). A configuration's lift per dollar summarizes its scored
|
|
2312
|
+
* searches as independent units with `summarizeSamples` (a percentile
|
|
2313
|
+
* bootstrap of the mean); a configuration with one scored search carries that
|
|
2314
|
+
* search's own paired interval, whose units are its test units. The signal
|
|
2315
|
+
* names the configuration with the largest estimate within one objective.
|
|
2316
|
+
*/
|
|
2317
|
+
/** The signal's name, as a policy or a view reads it. */
|
|
2318
|
+
const META_SEARCH_SIGNAL = "metaSearch.bestPolicyConfiguration";
|
|
2319
|
+
const DEFAULT_GLYPH_NODES = 64;
|
|
2320
|
+
/** The per-search score `metaSearchScore` computes. An outer search names it
|
|
2321
|
+
* as its objective's judge. */
|
|
2322
|
+
const META_SEARCH_SCORE_SOURCE = {
|
|
2323
|
+
uri: "npm:@tangle-network/agent-eval#metaSearchScore",
|
|
2324
|
+
revision: hashCanonical({
|
|
2325
|
+
name: "tangle.meta-search-score.2026-09",
|
|
2326
|
+
node: "the claim selection on ship; on hold, the first finalist in claim order not decided invalid",
|
|
2327
|
+
lift: "estimateNode(node, against root, split test), oriented so a gain is positive",
|
|
2328
|
+
unscored: [
|
|
2329
|
+
"open",
|
|
2330
|
+
"no claim",
|
|
2331
|
+
"claim the ledger contradicts (verifySearchClaim mismatch)",
|
|
2332
|
+
"test-cannot-resolve",
|
|
2333
|
+
"hold with no finalist tested",
|
|
2334
|
+
"fewer than 2 shared test units"
|
|
2335
|
+
],
|
|
2336
|
+
dollars: "committed spend of every cell and operation, claim cells included",
|
|
2337
|
+
unknownCost: "a floor only: the lift per dollar is a bound and enters no estimate"
|
|
2338
|
+
})
|
|
2339
|
+
};
|
|
2340
|
+
/**
|
|
2341
|
+
* The genome of a search: the configuration its ledger records. Declared
|
|
2342
|
+
* parts come from the header; the proposer comes from what the edges and
|
|
2343
|
+
* candidate-generation operations name, because the header declares none.
|
|
2344
|
+
*/
|
|
2345
|
+
function searchPolicyGenome(state) {
|
|
2346
|
+
const header = state.header;
|
|
2347
|
+
if (!header) throw new Error(`search ${state.searchId} has not been opened`);
|
|
2348
|
+
const { maxUsd, maxCells, maxNodes, maxConcurrency, reservedClaimUsd } = header.budget;
|
|
2349
|
+
const proposers = /* @__PURE__ */ new Map();
|
|
2350
|
+
const operations = /* @__PURE__ */ new Set();
|
|
2351
|
+
for (const edge of state.edges()) {
|
|
2352
|
+
if (!edge.proposer) continue;
|
|
2353
|
+
const { kind, name, source, operationId } = edge.proposer;
|
|
2354
|
+
const proposer = {
|
|
2355
|
+
kind,
|
|
2356
|
+
name,
|
|
2357
|
+
source
|
|
2358
|
+
};
|
|
2359
|
+
proposers.set(hashCanonical(proposer), proposer);
|
|
2360
|
+
if (operationId !== null) operations.add(operationId);
|
|
2361
|
+
}
|
|
2362
|
+
const executions = /* @__PURE__ */ new Map();
|
|
2363
|
+
for (const operationId of operations) {
|
|
2364
|
+
const execution = state.operation(operationId)?.execution;
|
|
2365
|
+
if (!execution) continue;
|
|
2366
|
+
const entry = execution.kind === "model" ? {
|
|
2367
|
+
kind: "model",
|
|
2368
|
+
model: execution.model
|
|
2369
|
+
} : {
|
|
2370
|
+
kind: "deterministic",
|
|
2371
|
+
source: execution.source
|
|
2372
|
+
};
|
|
2373
|
+
executions.set(hashCanonical(entry), entry);
|
|
2374
|
+
}
|
|
2375
|
+
return {
|
|
2376
|
+
expansion: withoutSearchSeed(header.policy.expansion, header.policy.seed),
|
|
2377
|
+
allocation: withoutSearchSeed(header.policy.allocation, header.policy.seed),
|
|
2378
|
+
budget: {
|
|
2379
|
+
maxUsd,
|
|
2380
|
+
maxCells,
|
|
2381
|
+
maxNodes,
|
|
2382
|
+
maxConcurrency,
|
|
2383
|
+
reservedClaimUsd
|
|
2384
|
+
},
|
|
2385
|
+
proposers: proposers.size > 0 ? sortedValues(proposers) : { unknown: "the search recorded no proposal, so its proposer is not in the ledger" },
|
|
2386
|
+
proposerExecution: executions.size > 0 ? sortedValues(executions) : { unknown: "no recorded candidate-generation operation names what ran the proposer" }
|
|
2387
|
+
};
|
|
2388
|
+
}
|
|
2389
|
+
/**
|
|
2390
|
+
* A search's held-out lift per known dollar, from its claim and its test
|
|
2391
|
+
* cells. Pure; it reads the test split, so a policy view (which holds no test
|
|
2392
|
+
* cells) cannot call it; an outer search reads it from a closed inner search.
|
|
2393
|
+
*/
|
|
2394
|
+
function metaSearchScore(state) {
|
|
2395
|
+
return scoreSearch(state, state.closed?.claim ? verifySearchClaim(state) : null);
|
|
2396
|
+
}
|
|
2397
|
+
function scoreSearch(state, verification) {
|
|
2398
|
+
const header = state.header;
|
|
2399
|
+
if (!header) throw new Error(`search ${state.searchId} has not been opened`);
|
|
2400
|
+
const { spend: audit } = state.audit;
|
|
2401
|
+
const spend = {
|
|
2402
|
+
knownUsd: audit.knownUsd,
|
|
2403
|
+
floorUsd: audit.floorUsd,
|
|
2404
|
+
unknownCost: audit.unknownCostCells + audit.unknownCostOperations
|
|
2405
|
+
};
|
|
2406
|
+
const unscored = (reason, detail, decision = null) => ({
|
|
2407
|
+
status: "unscored",
|
|
2408
|
+
decision,
|
|
2409
|
+
reason,
|
|
2410
|
+
detail,
|
|
2411
|
+
spend
|
|
2412
|
+
});
|
|
2413
|
+
const closed = state.closed;
|
|
2414
|
+
if (!closed) return unscored("open", "the search is open; its claim is made when it closes");
|
|
2415
|
+
const claim = closed.claim;
|
|
2416
|
+
if (!claim) return unscored("no-claim", `the search closed (${closed.reason}) without a claim on a test split`);
|
|
2417
|
+
if (verification?.status === "mismatch") return unscored("claim-mismatch", `the claim differs from what its ledger supports: ${verification.differences.join("; ")}`, claim.decision);
|
|
2418
|
+
if (claim.decision === "test-cannot-resolve") return unscored("test-cannot-resolve", claim.reason, claim.decision);
|
|
2419
|
+
const root = state.rootNodeId;
|
|
2420
|
+
const nodeId = claim.decision === "ship" ? claim.selected : claim.finalists.find((finalist) => state.node(finalist.nodeId)?.status !== "invalid")?.nodeId ?? null;
|
|
2421
|
+
if (nodeId === null || nodeId === root) return unscored("no-valid-finalist", claim.finalists.length === 0 ? `hold: no finalist went to test (${claim.reason})` : `hold: every finalist was decided invalid (${claim.finalists.length})`, claim.decision);
|
|
2422
|
+
const estimate = estimateNode(state, nodeId, {
|
|
2423
|
+
against: root,
|
|
2424
|
+
split: "test"
|
|
2425
|
+
});
|
|
2426
|
+
if (estimate.delta === null) return unscored("insufficient", `${nodeId} and the root share ${estimate.pairs} scored test unit${estimate.pairs === 1 ? "" : "s"}; a lift needs 2`, claim.decision);
|
|
2427
|
+
const maximize = header.objective.direction === "maximize";
|
|
2428
|
+
const lift = orient(estimate.delta, maximize);
|
|
2429
|
+
const liftInterval = estimate.interval ? orientInterval(estimate.interval, maximize) : null;
|
|
2430
|
+
return {
|
|
2431
|
+
status: "scored",
|
|
2432
|
+
decision: claim.decision,
|
|
2433
|
+
nodeId,
|
|
2434
|
+
estimate,
|
|
2435
|
+
lift,
|
|
2436
|
+
liftInterval,
|
|
2437
|
+
spend,
|
|
2438
|
+
liftPerUsd: liftPerUsd(lift, liftInterval, spend)
|
|
2439
|
+
};
|
|
2440
|
+
}
|
|
2441
|
+
/** The lens: every search as a node of a search of searches. */
|
|
2442
|
+
function metaSearch(searches, options = {}) {
|
|
2443
|
+
const glyphNodes = options.glyphNodes ?? DEFAULT_GLYPH_NODES;
|
|
2444
|
+
if (!Number.isSafeInteger(glyphNodes) || glyphNodes < 0) throw new TypeError(`metaSearch: glyphNodes must be a non-negative integer, got ${glyphNodes}`);
|
|
2445
|
+
const byId = /* @__PURE__ */ new Map();
|
|
2446
|
+
for (const state of searches) {
|
|
2447
|
+
if (!state.header) throw new Error(`metaSearch: search ${state.searchId} has not been opened`);
|
|
2448
|
+
if (byId.has(state.searchId)) throw new Error(`metaSearch: search ${state.searchId} was given twice`);
|
|
2449
|
+
byId.set(state.searchId, state);
|
|
2450
|
+
}
|
|
2451
|
+
const entries = searches.map((state) => entry(state, byId, glyphNodes));
|
|
2452
|
+
const entryById = new Map(entries.map((item) => [item.searchId, item]));
|
|
2453
|
+
for (const item of entries) if (item.parent?.present) entryById.get(item.parent.searchId).children.push(item.searchId);
|
|
2454
|
+
const roots = entries.filter((item) => !item.parent?.present).map((item) => item.searchId);
|
|
2455
|
+
const assignDepth = (searchId, depth, seen) => {
|
|
2456
|
+
if (seen.has(searchId)) return;
|
|
2457
|
+
seen.add(searchId);
|
|
2458
|
+
const item = entryById.get(searchId);
|
|
2459
|
+
item.depth = depth;
|
|
2460
|
+
for (const child of item.children) assignDepth(child, depth + 1, seen);
|
|
2461
|
+
};
|
|
2462
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2463
|
+
for (const root of roots) assignDepth(root, 0, seen);
|
|
2464
|
+
const configurations = configure(entries, byId);
|
|
2465
|
+
const objectives = [...new Set(entries.map((item) => item.objectiveKey))].sort(compareCodeUnits);
|
|
2466
|
+
const { objective, value } = best(configurations, objectives, options.objective);
|
|
2467
|
+
return {
|
|
2468
|
+
data: {
|
|
2469
|
+
searches: entries,
|
|
2470
|
+
roots,
|
|
2471
|
+
configurations,
|
|
2472
|
+
objectives,
|
|
2473
|
+
signalObjective: objective
|
|
2474
|
+
},
|
|
2475
|
+
signal: {
|
|
2476
|
+
name: META_SEARCH_SIGNAL,
|
|
2477
|
+
value
|
|
2478
|
+
}
|
|
2479
|
+
};
|
|
2480
|
+
}
|
|
2481
|
+
function entry(state, byId, glyphNodes) {
|
|
2482
|
+
const header = state.header;
|
|
2483
|
+
const genome = searchPolicyGenome(state);
|
|
2484
|
+
const closed = state.closed;
|
|
2485
|
+
let claim = null;
|
|
2486
|
+
const verification = closed?.claim ? verifySearchClaim(state) : null;
|
|
2487
|
+
if (closed?.claim) claim = {
|
|
2488
|
+
decision: closed.claim.decision,
|
|
2489
|
+
selected: closed.claim.selected,
|
|
2490
|
+
verification: verification?.status ?? "unknown",
|
|
2491
|
+
reason: closed.claim.reason
|
|
2492
|
+
};
|
|
2493
|
+
const nodes = state.nodes();
|
|
2494
|
+
const cells = state.cells();
|
|
2495
|
+
return {
|
|
2496
|
+
searchId: state.searchId,
|
|
2497
|
+
subject: header.subject,
|
|
2498
|
+
objective: {
|
|
2499
|
+
metric: header.objective.metric,
|
|
2500
|
+
direction: header.objective.direction
|
|
2501
|
+
},
|
|
2502
|
+
objectiveKey: objectiveKey(header),
|
|
2503
|
+
status: closed ? "closed" : "open",
|
|
2504
|
+
closeReason: closed?.reason ?? null,
|
|
2505
|
+
genome,
|
|
2506
|
+
genomeDigest: hashCanonical(genome),
|
|
2507
|
+
seed: header.policy.seed,
|
|
2508
|
+
rootArtifactDigest: nodes[0]?.artifactDigest ?? null,
|
|
2509
|
+
nodes: nodes.length,
|
|
2510
|
+
cells: {
|
|
2511
|
+
settled: state.audit.cells.settled,
|
|
2512
|
+
test: cells.filter((cell) => cell.split === "test").length
|
|
2513
|
+
},
|
|
2514
|
+
claim,
|
|
2515
|
+
score: scoreSearch(state, verification),
|
|
2516
|
+
parent: parentOf(state, byId),
|
|
2517
|
+
children: [],
|
|
2518
|
+
depth: 0,
|
|
2519
|
+
glyph: {
|
|
2520
|
+
nodes: nodes.slice(0, glyphNodes).map((node) => [
|
|
2521
|
+
node.nodeId,
|
|
2522
|
+
node.primaryParentId,
|
|
2523
|
+
node.status
|
|
2524
|
+
]),
|
|
2525
|
+
omitted: Math.max(0, nodes.length - glyphNodes)
|
|
2526
|
+
}
|
|
2527
|
+
};
|
|
2528
|
+
}
|
|
2529
|
+
/** `subject · metric (direction)`: the key searches are compared within. */
|
|
2530
|
+
function objectiveKey(header) {
|
|
2531
|
+
return `${header.subject} · ${header.objective.metric} (${header.objective.direction})`;
|
|
2532
|
+
}
|
|
2533
|
+
function parentOf(state, byId) {
|
|
2534
|
+
const header = state.header;
|
|
2535
|
+
if (header.containment) {
|
|
2536
|
+
const { searchId, cellId, attempt } = header.containment;
|
|
2537
|
+
const outer = byId.get(searchId);
|
|
2538
|
+
return {
|
|
2539
|
+
relation: "contained",
|
|
2540
|
+
searchId,
|
|
2541
|
+
cellId,
|
|
2542
|
+
attempt,
|
|
2543
|
+
nodeId: outer?.cell(cellId)?.nodeId ?? null,
|
|
2544
|
+
present: outer !== void 0
|
|
2545
|
+
};
|
|
2546
|
+
}
|
|
2547
|
+
if (header.derivedFrom) {
|
|
2548
|
+
const { searchId, nodeId } = header.derivedFrom;
|
|
2549
|
+
return {
|
|
2550
|
+
relation: "derived",
|
|
2551
|
+
searchId,
|
|
2552
|
+
nodeId,
|
|
2553
|
+
present: byId.has(searchId)
|
|
2554
|
+
};
|
|
2555
|
+
}
|
|
2556
|
+
return null;
|
|
2557
|
+
}
|
|
2558
|
+
function configure(entries, byId) {
|
|
2559
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2560
|
+
for (const item of entries) {
|
|
2561
|
+
const key = configurationKey(item.objectiveKey, item.genomeDigest);
|
|
2562
|
+
const group = groups.get(key);
|
|
2563
|
+
if (group) group.push(item);
|
|
2564
|
+
else groups.set(key, [item]);
|
|
2565
|
+
}
|
|
2566
|
+
const genomeOfOuterNode = /* @__PURE__ */ new Map();
|
|
2567
|
+
for (const item of entries) if (item.parent?.relation === "contained" && item.parent.nodeId !== null) genomeOfOuterNode.set(`${item.parent.searchId}\u0000${item.parent.nodeId}`, item.genomeDigest);
|
|
2568
|
+
return [...groups.values()].map((group) => {
|
|
2569
|
+
const first = group[0];
|
|
2570
|
+
const decisions = {
|
|
2571
|
+
ship: 0,
|
|
2572
|
+
hold: 0,
|
|
2573
|
+
"test-cannot-resolve": 0,
|
|
2574
|
+
none: 0
|
|
2575
|
+
};
|
|
2576
|
+
const unscored = {};
|
|
2577
|
+
const values = [];
|
|
2578
|
+
let single = null;
|
|
2579
|
+
let bounded = 0;
|
|
2580
|
+
for (const item of group) {
|
|
2581
|
+
decisions[item.claim?.decision ?? "none"] += 1;
|
|
2582
|
+
const score = item.score;
|
|
2583
|
+
if (score.status === "unscored") {
|
|
2584
|
+
unscored[score.reason] = (unscored[score.reason] ?? 0) + 1;
|
|
2585
|
+
continue;
|
|
2586
|
+
}
|
|
2587
|
+
if (score.liftPerUsd.status === "bound") bounded += 1;
|
|
2588
|
+
if (score.liftPerUsd.status !== "known") continue;
|
|
2589
|
+
values.push(score.liftPerUsd.value);
|
|
2590
|
+
single = {
|
|
2591
|
+
basis: "test-units",
|
|
2592
|
+
n: score.estimate.pairs,
|
|
2593
|
+
value: score.liftPerUsd.value,
|
|
2594
|
+
method: score.estimate.method,
|
|
2595
|
+
interval: score.liftPerUsd.interval,
|
|
2596
|
+
intervalMethod: score.liftPerUsd.interval ? "paired-delta-test-over-usd" : null
|
|
2597
|
+
};
|
|
2598
|
+
}
|
|
2599
|
+
const estimate = values.length === 1 && single ? single : fromSummary(summarizeSamples(values, seedFromDigest(first.genomeDigest)));
|
|
2600
|
+
const outerNodes = /* @__PURE__ */ new Map();
|
|
2601
|
+
for (const item of group) {
|
|
2602
|
+
const parent = item.parent;
|
|
2603
|
+
if (parent?.relation !== "contained" || parent.nodeId === null) continue;
|
|
2604
|
+
const key = `${parent.searchId}\u0000${parent.nodeId}`;
|
|
2605
|
+
if (outerNodes.has(key)) continue;
|
|
2606
|
+
const outer = byId.get(parent.searchId);
|
|
2607
|
+
const parentNodeId = outer.node(parent.nodeId).primaryParentId;
|
|
2608
|
+
outerNodes.set(key, {
|
|
2609
|
+
searchId: parent.searchId,
|
|
2610
|
+
nodeId: parent.nodeId,
|
|
2611
|
+
parentNodeId,
|
|
2612
|
+
parentGenomeDigest: parentNodeId === null ? null : genomeOfOuterNode.get(`${parent.searchId}\u0000${parentNodeId}`) ?? null,
|
|
2613
|
+
againstParent: parentNodeId === null ? null : estimateNode(outer, parent.nodeId, {
|
|
2614
|
+
against: parentNodeId,
|
|
2615
|
+
split: rankingSplit(outer)
|
|
2616
|
+
})
|
|
2617
|
+
});
|
|
2618
|
+
}
|
|
2619
|
+
return {
|
|
2620
|
+
genomeDigest: first.genomeDigest,
|
|
2621
|
+
objectiveKey: first.objectiveKey,
|
|
2622
|
+
genome: first.genome,
|
|
2623
|
+
searches: group.map((item) => item.searchId),
|
|
2624
|
+
decisions,
|
|
2625
|
+
scored: values.length,
|
|
2626
|
+
bounded,
|
|
2627
|
+
unscored,
|
|
2628
|
+
estimate,
|
|
2629
|
+
outerNodes: [...outerNodes.values()]
|
|
2630
|
+
};
|
|
2631
|
+
}).sort((left, right) => rankValue(right.estimate.value) - rankValue(left.estimate.value) || right.scored - left.scored || compareCodeUnits(left.objectiveKey, right.objectiveKey) || compareCodeUnits(left.genomeDigest, right.genomeDigest));
|
|
2632
|
+
}
|
|
2633
|
+
function best(configurations, objectives, requested) {
|
|
2634
|
+
const empty = (objective, reason) => ({
|
|
2635
|
+
objective,
|
|
2636
|
+
value: {
|
|
2637
|
+
liftPerUsd: null,
|
|
2638
|
+
objectiveKey: objective,
|
|
2639
|
+
genomeDigest: null,
|
|
2640
|
+
genome: null,
|
|
2641
|
+
interval: null,
|
|
2642
|
+
method: null,
|
|
2643
|
+
intervalMethod: null,
|
|
2644
|
+
basis: null,
|
|
2645
|
+
n: 0,
|
|
2646
|
+
searches: 0,
|
|
2647
|
+
scored: 0,
|
|
2648
|
+
outerNode: null,
|
|
2649
|
+
reason
|
|
2650
|
+
}
|
|
2651
|
+
});
|
|
2652
|
+
if (configurations.length === 0) return empty(null, "no search was given");
|
|
2653
|
+
const measured = [...new Set(configurations.filter((item) => item.estimate.value !== null).map((item) => item.objectiveKey))].sort(compareCodeUnits);
|
|
2654
|
+
let objective;
|
|
2655
|
+
if (requested !== void 0) {
|
|
2656
|
+
if (!objectives.includes(requested)) return empty(requested, `no search given has objective ${requested}; the searches have ${objectives.join("; ")}`);
|
|
2657
|
+
objective = requested;
|
|
2658
|
+
} else if (measured.length > 1) return empty(null, `searches with a known lift per dollar span ${measured.length} objectives (${measured.join("; ")}); a lift in one metric does not compare with another, so name one`);
|
|
2659
|
+
else {
|
|
2660
|
+
objective = measured[0] ?? (objectives.length === 1 ? objectives[0] : "");
|
|
2661
|
+
if (objective === "") return empty(null, `no configuration has a known lift per dollar, across ${objectives.length} objectives`);
|
|
2662
|
+
}
|
|
2663
|
+
const within = configurations.filter((item) => item.objectiveKey === objective);
|
|
2664
|
+
const withValue = within.filter((item) => item.estimate.value !== null);
|
|
2665
|
+
const top = withValue[0];
|
|
2666
|
+
if (!top) {
|
|
2667
|
+
const unscored = within.reduce((total, item) => total + Object.values(item.unscored).reduce((sum, count) => sum + (count ?? 0), 0), 0);
|
|
2668
|
+
const bounded = within.reduce((total, item) => total + item.bounded, 0);
|
|
2669
|
+
return empty(objective, `no configuration of ${objective} has a known lift per dollar: ${unscored} unscored search${unscored === 1 ? "" : "es"} and ${bounded} with only a spend floor`);
|
|
2670
|
+
}
|
|
2671
|
+
const { estimate } = top;
|
|
2672
|
+
const runnerUp = withValue[1];
|
|
2673
|
+
const coverage = `${top.scored} of ${top.searches.length} of its searches scored`;
|
|
2674
|
+
return {
|
|
2675
|
+
objective,
|
|
2676
|
+
value: {
|
|
2677
|
+
liftPerUsd: estimate.value,
|
|
2678
|
+
objectiveKey: objective,
|
|
2679
|
+
genomeDigest: top.genomeDigest,
|
|
2680
|
+
genome: top.genome,
|
|
2681
|
+
interval: estimate.interval,
|
|
2682
|
+
method: estimate.method,
|
|
2683
|
+
intervalMethod: estimate.intervalMethod,
|
|
2684
|
+
basis: estimate.basis,
|
|
2685
|
+
n: estimate.n,
|
|
2686
|
+
searches: top.searches.length,
|
|
2687
|
+
scored: top.scored,
|
|
2688
|
+
outerNode: top.outerNodes[0] ? {
|
|
2689
|
+
searchId: top.outerNodes[0].searchId,
|
|
2690
|
+
nodeId: top.outerNodes[0].nodeId
|
|
2691
|
+
} : null,
|
|
2692
|
+
reason: runnerUp === void 0 ? `the only configuration with a known lift per dollar (${estimate.method}, ${estimate.basis} n=${estimate.n}; ${coverage})` : `the largest of ${withValue.length} configurations by estimated lift per dollar (${estimate.method}, ${estimate.basis} n=${estimate.n}; ${coverage}); a point ranking, not a test`
|
|
2693
|
+
}
|
|
2694
|
+
};
|
|
2695
|
+
}
|
|
2696
|
+
function liftPerUsd(lift, interval, spend) {
|
|
2697
|
+
const committed = spend.knownUsd + spend.floorUsd;
|
|
2698
|
+
if (spend.unknownCost > 0) {
|
|
2699
|
+
if (committed <= 0) return {
|
|
2700
|
+
status: "unknown",
|
|
2701
|
+
reason: `${spend.unknownCost} cost${spend.unknownCost === 1 ? " is" : "s are"} unknown with no proven floor`
|
|
2702
|
+
};
|
|
2703
|
+
return {
|
|
2704
|
+
status: "bound",
|
|
2705
|
+
value: lift / committed,
|
|
2706
|
+
bound: lift >= 0 ? "at-most" : "at-least",
|
|
2707
|
+
reason: `${spend.unknownCost} cost${spend.unknownCost === 1 ? " is" : "s are"} known only as a floor; $${round(committed)} is the least the search spent`
|
|
2708
|
+
};
|
|
2709
|
+
}
|
|
2710
|
+
if (spend.knownUsd <= 0) return {
|
|
2711
|
+
status: "unknown",
|
|
2712
|
+
reason: "the search recorded no spend, so a lift per dollar is undefined"
|
|
2713
|
+
};
|
|
2714
|
+
return {
|
|
2715
|
+
status: "known",
|
|
2716
|
+
value: lift / spend.knownUsd,
|
|
2717
|
+
interval: interval ? [interval[0] / spend.knownUsd, interval[1] / spend.knownUsd] : null
|
|
2718
|
+
};
|
|
2719
|
+
}
|
|
2720
|
+
function fromSummary(summary) {
|
|
2721
|
+
return {
|
|
2722
|
+
basis: "searches",
|
|
2723
|
+
n: summary.n,
|
|
2724
|
+
value: summary.mean,
|
|
2725
|
+
method: summary.n < 2 ? "none" : summary.method,
|
|
2726
|
+
interval: summary.interval,
|
|
2727
|
+
intervalMethod: summary.interval ? "percentile-bootstrap-of-mean" : null
|
|
2728
|
+
};
|
|
2729
|
+
}
|
|
2730
|
+
/**
|
|
2731
|
+
* The lens as text, for an agent: the signal, every configuration best first
|
|
2732
|
+
* with its coverage and interval, and the forest of searches with contained
|
|
2733
|
+
* searches grouped under the outer node that ran them. `agent-eval search show
|
|
2734
|
+
* --meta` prints it.
|
|
2735
|
+
*/
|
|
2736
|
+
function renderMetaSearchText(result, options = {}) {
|
|
2737
|
+
const perGroup = options.searchesPerGroup ?? 5;
|
|
2738
|
+
const { data, signal } = result;
|
|
2739
|
+
const lines = [];
|
|
2740
|
+
const contained = data.searches.filter((item) => item.parent?.relation === "contained").length;
|
|
2741
|
+
const derived = data.searches.filter((item) => item.parent?.relation === "derived").length;
|
|
2742
|
+
lines.push(`meta-search: ${plural(data.searches.length, "search", "searches")} (${contained} contained, ${derived} derived) in ${plural(data.objectives.length, "objective")}, ${plural(data.configurations.length, "configuration")}`);
|
|
2743
|
+
const value = signal.value;
|
|
2744
|
+
lines.push(value.liftPerUsd === null ? `${signal.name}: unknown — ${value.reason}` : `${signal.name}: ${value.genomeDigest.slice(0, 19)} lift per known $ ${formatNumber(value.liftPerUsd)} ${formatInterval(value.interval, value.intervalMethod)} (${value.method}, ${value.basis} n=${value.n}); ${value.reason}`);
|
|
2745
|
+
const index = new Map(data.configurations.map((item, position) => [configurationKey(item.objectiveKey, item.genomeDigest), position]));
|
|
2746
|
+
for (const objective of data.objectives) {
|
|
2747
|
+
lines.push("", `configurations of ${objective}, best first:`);
|
|
2748
|
+
for (const configuration of data.configurations) {
|
|
2749
|
+
if (configuration.objectiveKey !== objective) continue;
|
|
2750
|
+
lines.push(...configurationLines(configuration, index));
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
lines.push("", "searches, by derivation and containment:");
|
|
2754
|
+
const byId = new Map(data.searches.map((item) => [item.searchId, item]));
|
|
2755
|
+
const walk = (searchId, depth) => {
|
|
2756
|
+
const item = byId.get(searchId);
|
|
2757
|
+
const pad = " ".repeat(depth + 1);
|
|
2758
|
+
lines.push(`${pad}${searchLine(item, index)}`);
|
|
2759
|
+
const derivedChildren = item.children.filter((child) => byId.get(child).parent?.relation === "derived");
|
|
2760
|
+
for (const child of derivedChildren) walk(child, depth + 1);
|
|
2761
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2762
|
+
for (const child of item.children) {
|
|
2763
|
+
const childItem = byId.get(child);
|
|
2764
|
+
if (childItem.parent?.relation !== "contained") continue;
|
|
2765
|
+
const key = childItem.parent.nodeId ?? "(unknown node)";
|
|
2766
|
+
const group = groups.get(key);
|
|
2767
|
+
if (group) group.push(childItem);
|
|
2768
|
+
else groups.set(key, [childItem]);
|
|
2769
|
+
}
|
|
2770
|
+
for (const [nodeId, group] of groups) {
|
|
2771
|
+
const shown = group.slice(0, perGroup);
|
|
2772
|
+
const tally = decisionTally(group);
|
|
2773
|
+
lines.push(`${pad} node ${nodeId}: ${plural(group.length, "contained search", "contained searches")} (${tally})`);
|
|
2774
|
+
for (const child of shown) walk(child.searchId, depth + 2);
|
|
2775
|
+
if (group.length > shown.length) lines.push(`${" ".repeat(depth + 3)}… ${group.length - shown.length} more`);
|
|
2776
|
+
}
|
|
2777
|
+
};
|
|
2778
|
+
for (const root of data.roots) walk(root, 0);
|
|
2779
|
+
return lines.join("\n");
|
|
2780
|
+
}
|
|
2781
|
+
function configurationLines(configuration, index) {
|
|
2782
|
+
const { estimate, genome } = configuration;
|
|
2783
|
+
const decisions = Object.entries(configuration.decisions).filter(([, count]) => count > 0).map(([decision, count]) => `${decision} ${count}`).join(", ");
|
|
2784
|
+
const unscored = Object.entries(configuration.unscored).map(([reason, count]) => `${reason} ${count}`).join(", ");
|
|
2785
|
+
const value = estimate.value === null ? "lift per known $: unknown" : `lift per known $ ${formatNumber(estimate.value)} ${formatInterval(estimate.interval, estimate.intervalMethod)} (${estimate.method}, ${estimate.basis} n=${estimate.n})`;
|
|
2786
|
+
const lines = [` #${index.get(configurationKey(configuration.objectiveKey, configuration.genomeDigest)) + 1} ${configuration.genomeDigest.slice(0, 19)} ${genomeLine(genome)}`, ` ${plural(configuration.searches.length, "search", "searches")}: ${decisions}; scored ${configuration.scored}${configuration.bounded > 0 ? `, spend floor only ${configuration.bounded}` : ""}${unscored ? `, unscored: ${unscored}` : ""}; ${value}`];
|
|
2787
|
+
for (const outer of configuration.outerNodes) {
|
|
2788
|
+
const against = outer.againstParent;
|
|
2789
|
+
const parentIndex = outer.parentGenomeDigest === null ? void 0 : index.get(configurationKey(configuration.objectiveKey, outer.parentGenomeDigest));
|
|
2790
|
+
lines.push(against === null ? ` outer node ${outer.nodeId} in ${outer.searchId}: the outer root` : ` outer node ${outer.nodeId} in ${outer.searchId}, from ${parentIndex === void 0 ? outer.parentNodeId : `#${parentIndex + 1}`}: ${estimateText(against)}`);
|
|
2791
|
+
}
|
|
2792
|
+
return lines;
|
|
2793
|
+
}
|
|
2794
|
+
function searchLine(item, index) {
|
|
2795
|
+
const claim = item.claim ? `claim ${item.claim.decision} (${item.claim.verification})` : "no claim";
|
|
2796
|
+
const configuration = index.get(configurationKey(item.objectiveKey, item.genomeDigest));
|
|
2797
|
+
const score = item.score;
|
|
2798
|
+
const scoreText = score.status === "unscored" ? `unscored: ${score.reason} — ${score.detail}` : `lift ${formatNumber(score.lift)} ${formatInterval(score.liftInterval, score.liftInterval ? "paired-delta-test" : null)} on ${score.estimate.pairs} test units (${score.estimate.method}); ${liftPerUsdText(score.liftPerUsd)}`;
|
|
2799
|
+
const relation = item.parent === null ? "" : item.parent.relation === "derived" ? ` derived from ${item.parent.searchId}/${item.parent.nodeId}${item.parent.present ? "" : " (not given)"};` : item.parent.present ? "" : ` contained in ${item.parent.searchId} cell ${item.parent.cellId} (not given);`;
|
|
2800
|
+
return `${item.searchId} [${item.status}${item.closeReason ? `: ${item.closeReason}` : ""}; ${claim}; config #${configuration === void 0 ? "?" : configuration + 1}; ${item.nodes} nodes, ${item.cells.settled} cells, $${round(item.score.spend.knownUsd)} known${item.score.spend.unknownCost > 0 ? ` + $${round(item.score.spend.floorUsd)} floor over ${item.score.spend.unknownCost} unknown` : ""}]${relation} ${scoreText}`;
|
|
2801
|
+
}
|
|
2802
|
+
function genomeLine(genome) {
|
|
2803
|
+
const budget = Object.entries(genome.budget).filter(([, value]) => value !== null && value !== 0).map(([key, value]) => `${key}=${value}`).join(" ");
|
|
2804
|
+
const proposers = Array.isArray(genome.proposers) ? genome.proposers.map((proposer) => `${proposer.kind}:${proposer.name}`).join(", ") : "unknown";
|
|
2805
|
+
const execution = Array.isArray(genome.proposerExecution) ? genome.proposerExecution.map((item) => item.kind === "model" ? `model ${item.model.provider}/${"snapshot" in item.model ? item.model.snapshot : `${item.model.alias} (snapshot unknown)`}` : "deterministic").join(", ") : "unknown";
|
|
2806
|
+
return `expansion=${genome.expansion} allocation=${genome.allocation}${budget ? ` ${budget}` : ""} proposer=${proposers} (${execution})`;
|
|
2807
|
+
}
|
|
2808
|
+
function decisionTally(group) {
|
|
2809
|
+
const counts = /* @__PURE__ */ new Map();
|
|
2810
|
+
for (const item of group) {
|
|
2811
|
+
const key = item.score.status === "scored" ? `${item.score.decision} scored` : `${item.claim?.decision ?? "no claim"} unscored`;
|
|
2812
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
2813
|
+
}
|
|
2814
|
+
return [...counts.entries()].sort(([left], [right]) => compareCodeUnits(left, right)).map(([key, count]) => `${key} ${count}`).join(", ");
|
|
2815
|
+
}
|
|
2816
|
+
function estimateText(estimate) {
|
|
2817
|
+
if (estimate.delta === null) return `unknown (${plural(estimate.pairs, "shared unit")})`;
|
|
2818
|
+
if (estimate.method === "insufficient") return `${formatSigned(estimate.delta)} (insufficient: ${estimate.pairs} of ${INSUFFICIENT_FROM} units)`;
|
|
2819
|
+
return `${formatSigned(estimate.delta)} ${formatInterval(estimate.interval, estimate.interval ? "paired-delta-test" : null)} (${estimate.method}, ${estimate.pairs} ${estimate.split} units)`;
|
|
2820
|
+
}
|
|
2821
|
+
function liftPerUsdText(value) {
|
|
2822
|
+
if (value.status === "unknown") return `per $: unknown (${value.reason})`;
|
|
2823
|
+
if (value.status === "bound") return `per $: ${value.bound} ${formatNumber(value.value)} (${value.reason})`;
|
|
2824
|
+
return `per $: ${formatNumber(value.value)}`;
|
|
2825
|
+
}
|
|
2826
|
+
function formatInterval(interval, method) {
|
|
2827
|
+
if (interval === null) return "[no interval]";
|
|
2828
|
+
return `[${formatNumber(interval[0])}, ${formatNumber(interval[1])}]${method ? ` ${method}` : ""}`;
|
|
2829
|
+
}
|
|
2830
|
+
function formatNumber(value) {
|
|
2831
|
+
if (value === 0) return "0";
|
|
2832
|
+
return Math.abs(value) >= .01 ? value.toFixed(4) : value.toPrecision(3);
|
|
2833
|
+
}
|
|
2834
|
+
function formatSigned(value) {
|
|
2835
|
+
return value > 0 ? `+${formatNumber(value)}` : formatNumber(value);
|
|
2836
|
+
}
|
|
2837
|
+
function plural(count, one, many = `${one}s`) {
|
|
2838
|
+
return `${count} ${count === 1 ? one : many}`;
|
|
2839
|
+
}
|
|
2840
|
+
/** A policy or allocator name carries `seed=<n>` when it is seeded from the
|
|
2841
|
+
* search's own seed; the genome names it `seed=<search>` so searches of one
|
|
2842
|
+
* configuration on different seeds share a genome. */
|
|
2843
|
+
function withoutSearchSeed(name, seed) {
|
|
2844
|
+
return name.replace(new RegExp(`\\bseed=${seed}(?![\\d.])`, "g"), "seed=<search>");
|
|
2845
|
+
}
|
|
2846
|
+
function orient(delta, maximize) {
|
|
2847
|
+
return maximize ? delta : delta === 0 ? 0 : -delta;
|
|
2848
|
+
}
|
|
2849
|
+
function orientInterval(interval, maximize) {
|
|
2850
|
+
return maximize ? interval : [orient(interval[1], false), orient(interval[0], false)];
|
|
2851
|
+
}
|
|
2852
|
+
function sortedValues(map) {
|
|
2853
|
+
return [...map.entries()].sort(([left], [right]) => compareCodeUnits(left, right)).map(([, value]) => value);
|
|
2854
|
+
}
|
|
2855
|
+
function configurationKey(objective, genomeDigest) {
|
|
2856
|
+
return `${objective}\u0000${genomeDigest}`;
|
|
2857
|
+
}
|
|
2858
|
+
function rankValue(value) {
|
|
2859
|
+
return value === null ? Number.NEGATIVE_INFINITY : value;
|
|
2860
|
+
}
|
|
2861
|
+
function round(usd) {
|
|
2862
|
+
return Math.round(usd * 1e6) / 1e6;
|
|
2863
|
+
}
|
|
2864
|
+
//#endregion
|
|
2865
|
+
//#region src/search/lenses/skill-manifold.ts
|
|
2866
|
+
const METHODS = {
|
|
2867
|
+
identity: {
|
|
2868
|
+
model: "masked matrix factorization: each observed per-unit mean is b_u + P_i·Q_u on standardized scores, fitted by alternating ridge least squares over observed cells only; missing cells are masked, never filled",
|
|
2869
|
+
intrinsic: "k-fold cross-validation over observed cells (folds by a hash of node and unit), mean squared error of held-out cells by rank; the intrinsic dimension is the smallest rank within one standard error (across folds) of the best",
|
|
2870
|
+
nextUnit: "expected share of the variance of each leader pair’s contrast on the whole split that one more cell on the unit, run on both leaders, removes; each leader’s coordinates have the linear-Gaussian ridge posterior with loadings held fixed and noise equal to the cross-validated cell error; averaged over leader pairs",
|
|
2871
|
+
explained: "in-sample R² of each rank over the rank-0 model (unit means only), on the fitted rows; it always grows with rank, which is why the intrinsic dimension is cross-validated"
|
|
2872
|
+
},
|
|
2873
|
+
logit: {
|
|
2874
|
+
model: "multidimensional two-parameter item-response model: a node passes a unit with probability logistic(b_u + P_i·Q_u), its passes on the unit binomial over its cells; fitted by alternating Newton steps on the likelihood with standard-normal priors on P and Q (ridge) and a N(0, 10²) prior on b; missing cells are masked, never filled",
|
|
2875
|
+
intrinsic: "k-fold cross-validation over observed node-unit pass counts (folds by a hash of node and unit), binomial log loss per held-out cell by rank; the intrinsic dimension is the smallest rank within one standard error (across folds) of the best",
|
|
2876
|
+
nextUnit: "expected share of the variance of each leader pair’s contrast (predicted pass rate on the whole split) that one more cell on the unit, run on both leaders, removes; each leader’s coordinates have the Laplace posterior of the item-response model with loadings held fixed, and a cell’s information is its binomial variance at the leader’s predicted pass rate; averaged over leader pairs",
|
|
2877
|
+
explained: "in-sample McFadden pseudo-R² of each rank over the rank-0 model (unit easiness only), on the fitted rows; it always grows with rank, which is why the intrinsic dimension is cross-validated"
|
|
2878
|
+
}
|
|
2879
|
+
};
|
|
2880
|
+
/** The prior precision on a unit's log-odds intercept: N(0, 10²), weak
|
|
2881
|
+
* enough to leave any observed pass rate alone and strong enough to keep a
|
|
2882
|
+
* unit every node passed (or failed) finite. */
|
|
2883
|
+
const LOGIT_BIAS_PRECISION = .01;
|
|
2884
|
+
/** The largest move one Newton step makes in any coordinate, in log-odds. */
|
|
2885
|
+
const NEWTON_STEP_CAP = 4;
|
|
2886
|
+
const MAX_ITERATIONS = 300;
|
|
2887
|
+
/** Relative change of the objective that ends a fit. */
|
|
2888
|
+
const TOLERANCE = 1e-7;
|
|
2889
|
+
/**
|
|
2890
|
+
* The skill manifold of a search at rank `k` (`auto`: the cross-validated
|
|
2891
|
+
* intrinsic dimension, at least 1, and 1 when too few nodes cross-validate).
|
|
2892
|
+
* Pure: it reads the state, and `calibration` when given.
|
|
2893
|
+
*/
|
|
2894
|
+
function skillManifold(state, k = "auto", options = {}) {
|
|
2895
|
+
const maxRank = positiveInteger("skillManifold", "maxRank", options.maxRank ?? 4);
|
|
2896
|
+
const folds = positiveInteger("skillManifold", "folds", options.folds ?? 5);
|
|
2897
|
+
if (folds < 2) throw new TypeError(`skillManifold: folds must be at least 2, got ${folds}`);
|
|
2898
|
+
const leaderCount = positiveInteger("skillManifold", "leaders", options.leaders ?? 3);
|
|
2899
|
+
const ridge = options.ridge ?? 1;
|
|
2900
|
+
if (!(ridge > 0) || !Number.isFinite(ridge)) throw new TypeError(`skillManifold: ridge must be a positive number, got ${String(ridge)}`);
|
|
2901
|
+
const sampleRows = positiveInteger("skillManifold", "sampleRows", options.sampleRows ?? 1e3);
|
|
2902
|
+
if (k !== "auto") positiveInteger("skillManifold", "k", k);
|
|
2903
|
+
const requested = options.model ?? "auto";
|
|
2904
|
+
if (requested !== "auto" && requested !== "linear" && requested !== "logistic") throw new TypeError(`skillManifold: model must be auto, linear or logistic, got ${String(requested)}`);
|
|
2905
|
+
const header = state.header;
|
|
2906
|
+
const split = options.split ?? rankingSplit(state);
|
|
2907
|
+
const direction = header?.objective.direction ?? "maximize";
|
|
2908
|
+
const sign = direction === "maximize" ? 1 : -1;
|
|
2909
|
+
const calibration = options.calibration ?? null;
|
|
2910
|
+
const source = calibration?.source ?? `${state.searchId}@${headSequence(state)}`;
|
|
2911
|
+
let link = calibration?.link ?? (requested === "logistic" ? "logit" : "identity");
|
|
2912
|
+
const empty = (reason, matrix) => result(state, link, {
|
|
2913
|
+
split,
|
|
2914
|
+
direction,
|
|
2915
|
+
mode: calibration ? "calibrated" : "fitted",
|
|
2916
|
+
source,
|
|
2917
|
+
matrix: matrix ?? {
|
|
2918
|
+
rows: 0,
|
|
2919
|
+
units: 0,
|
|
2920
|
+
observed: 0,
|
|
2921
|
+
density: null,
|
|
2922
|
+
excludedNodes: 0,
|
|
2923
|
+
excludedUnits: 0,
|
|
2924
|
+
fitRows: 0
|
|
2925
|
+
},
|
|
2926
|
+
model: null,
|
|
2927
|
+
explained: null,
|
|
2928
|
+
intrinsicDimension: {
|
|
2929
|
+
value: null,
|
|
2930
|
+
method: METHODS[link].intrinsic,
|
|
2931
|
+
n: 0,
|
|
2932
|
+
folds,
|
|
2933
|
+
curve: [],
|
|
2934
|
+
insufficient: reason
|
|
2935
|
+
},
|
|
2936
|
+
axes: axesNote(direction),
|
|
2937
|
+
noiseVariance: null,
|
|
2938
|
+
nodes: [],
|
|
2939
|
+
units: [],
|
|
2940
|
+
leaders: [],
|
|
2941
|
+
nextUnits: [],
|
|
2942
|
+
batch: null,
|
|
2943
|
+
insufficient: reason
|
|
2944
|
+
});
|
|
2945
|
+
if (!header || state.rootNodeId === null) return empty(header ? "the search has no root node yet" : "the search has not been opened");
|
|
2946
|
+
const matrix = scoreMatrix(state, split, options.asOfSequence, calibration);
|
|
2947
|
+
const shape = {
|
|
2948
|
+
rows: matrix.rows.length,
|
|
2949
|
+
units: matrix.unitIds.length,
|
|
2950
|
+
observed: matrix.observed,
|
|
2951
|
+
density: matrix.rows.length * matrix.unitIds.length > 0 ? round9(matrix.observed / (matrix.rows.length * matrix.unitIds.length)) : null,
|
|
2952
|
+
excludedNodes: matrix.excludedNodes,
|
|
2953
|
+
excludedUnits: matrix.excludedUnits,
|
|
2954
|
+
fitRows: 0
|
|
2955
|
+
};
|
|
2956
|
+
if (!calibration) {
|
|
2957
|
+
if (requested === "logistic" && !matrix.binary) throw new TypeError("skillManifold: model logistic needs every modelled cell to score 0 or 1; use auto or linear");
|
|
2958
|
+
link = requested === "linear" || !matrix.binary ? "identity" : "logit";
|
|
2959
|
+
} else if (link === "logit" && !matrix.binary) return empty(`the calibration from ${calibration.source} is an item-response model, and this search has cells that score other than 0 or 1`, shape);
|
|
2960
|
+
let center;
|
|
2961
|
+
let scale;
|
|
2962
|
+
let fit;
|
|
2963
|
+
let rank;
|
|
2964
|
+
let noiseVariance;
|
|
2965
|
+
let intrinsic;
|
|
2966
|
+
let explained = null;
|
|
2967
|
+
let iterations = 0;
|
|
2968
|
+
let converged = true;
|
|
2969
|
+
if (calibration) {
|
|
2970
|
+
if (matrix.rows.length === 0 || matrix.unitIds.length === 0) return empty("no node scored a calibrated unit", shape);
|
|
2971
|
+
center = calibration.center;
|
|
2972
|
+
scale = calibration.scale;
|
|
2973
|
+
rank = calibration.rank;
|
|
2974
|
+
const byUnit = new Map(calibration.units.map((unit) => [unit.unitId, unit]));
|
|
2975
|
+
fit = {
|
|
2976
|
+
rank,
|
|
2977
|
+
bias: matrix.unitIds.map((unitId) => byUnit.get(unitId).bias),
|
|
2978
|
+
loadings: matrix.unitIds.map((unitId) => [...byUnit.get(unitId).loading]),
|
|
2979
|
+
byRow: /* @__PURE__ */ new Map(),
|
|
2980
|
+
coordinates: [],
|
|
2981
|
+
iterations: 0,
|
|
2982
|
+
converged: true
|
|
2983
|
+
};
|
|
2984
|
+
placeRows(matrix, fit, center, scale, calibration.ridge, link);
|
|
2985
|
+
noiseVariance = calibration.noiseVariance;
|
|
2986
|
+
intrinsic = {
|
|
2987
|
+
value: null,
|
|
2988
|
+
method: METHODS[link].intrinsic,
|
|
2989
|
+
n: 0,
|
|
2990
|
+
folds,
|
|
2991
|
+
curve: [],
|
|
2992
|
+
insufficient: `loadings come from ${calibration.source}; its fit decided the rank (${rank})`
|
|
2993
|
+
};
|
|
2994
|
+
} else {
|
|
2995
|
+
if (matrix.rows.length < 3 || matrix.unitIds.length < INSUFFICIENT_FROM) return empty(`${plural$1(matrix.rows.length, "node")} and ${plural$1(matrix.unitIds.length, "unit")} qualify (a unit counts when 2 or more nodes scored it, a node when it scored 2 or more such units); a manifold needs 3 nodes and ${INSUFFICIENT_FROM} units`, shape);
|
|
2996
|
+
const standard = standardization(matrix);
|
|
2997
|
+
if (standard === null) return empty("every node scores the same on every unit, so there is no variation to factor", shape);
|
|
2998
|
+
({center, scale} = link === "logit" ? {
|
|
2999
|
+
center: 0,
|
|
3000
|
+
scale: 1
|
|
3001
|
+
} : standard);
|
|
3002
|
+
const fitRows = evenSample(matrix.rows.map((_, index) => index), sampleRows);
|
|
3003
|
+
shape.fitRows = fitRows.length;
|
|
3004
|
+
const cells = observedCells(matrix, fitRows, center, scale);
|
|
3005
|
+
const rankCap = Math.min(maxRank, matrix.unitIds.length - 1, fitRows.length - 1);
|
|
3006
|
+
intrinsic = crossValidate(matrix, cells, rankCap, folds, ridge, link);
|
|
3007
|
+
rank = k === "auto" ? Math.max(1, intrinsic.value ?? 1) : Math.min(k, Math.max(1, rankCap));
|
|
3008
|
+
const fits = [];
|
|
3009
|
+
for (let r = 0; r <= Math.max(rank, rankCap); r++) fits.push(factor(matrix.unitIds.length, cells, r, ridge, link));
|
|
3010
|
+
const base = loss(fits[0], cells, link);
|
|
3011
|
+
explained = {
|
|
3012
|
+
byRank: fits.slice(1).map((entry) => ({
|
|
3013
|
+
rank: entry.rank,
|
|
3014
|
+
r2: round9(base > 0 ? 1 - loss(entry, cells, link) / base : 0)
|
|
3015
|
+
})),
|
|
3016
|
+
axisShare: [],
|
|
3017
|
+
method: METHODS[link].explained
|
|
3018
|
+
};
|
|
3019
|
+
fit = fits[rank];
|
|
3020
|
+
iterations = fit.iterations;
|
|
3021
|
+
converged = fit.converged;
|
|
3022
|
+
const cv = intrinsic.curve.find((point) => point.rank === rank);
|
|
3023
|
+
noiseVariance = link === "identity" && cv && Number.isFinite(cv.error) ? cv.error : null;
|
|
3024
|
+
const shares = principalAxes(fit, fitRows);
|
|
3025
|
+
explained.axisShare = shares.map(round9);
|
|
3026
|
+
fit.coordinates = [];
|
|
3027
|
+
placeRows(matrix, fit, center, scale, ridge, link);
|
|
3028
|
+
}
|
|
3029
|
+
orientAxes(fit, sign);
|
|
3030
|
+
const unitCount = matrix.unitIds.length;
|
|
3031
|
+
const meanLoading = new Array(rank).fill(0);
|
|
3032
|
+
for (const loading of fit.loadings) for (let d = 0; d < rank; d++) meanLoading[d] += loading[d] / unitCount;
|
|
3033
|
+
const meanBias = fit.bias.reduce((sum, value) => sum + value, 0) / unitCount;
|
|
3034
|
+
const predicted = (coordinates) => {
|
|
3035
|
+
if (link === "identity") return center + scale * (meanBias + dot(coordinates, meanLoading));
|
|
3036
|
+
let total = 0;
|
|
3037
|
+
for (let u = 0; u < unitCount; u++) total += logistic(fit.bias[u] + dot(coordinates, fit.loadings[u]));
|
|
3038
|
+
return total / unitCount;
|
|
3039
|
+
};
|
|
3040
|
+
const leaderFloor = Math.min(INSUFFICIENT_FROM, unitCount);
|
|
3041
|
+
let leaderRows;
|
|
3042
|
+
if (options.candidates) {
|
|
3043
|
+
const wanted = new Set(options.candidates);
|
|
3044
|
+
leaderRows = matrix.rows.flatMap((row, index) => wanted.has(row.nodeId) ? [index] : []);
|
|
3045
|
+
} else leaderRows = matrix.rows.map((row, index) => ({
|
|
3046
|
+
row,
|
|
3047
|
+
index
|
|
3048
|
+
})).filter(({ row }) => row.observed.size >= leaderFloor).sort((left, right) => sign * (predicted(fit.coordinates[right.index]) - predicted(fit.coordinates[left.index])) || left.row.ordinal - right.row.ordinal).slice(0, leaderCount).map(({ index }) => index);
|
|
3049
|
+
const leaderSet = new Set(leaderRows);
|
|
3050
|
+
const next = intrinsic.value === 0 ? {
|
|
3051
|
+
ranked: [],
|
|
3052
|
+
batch: null,
|
|
3053
|
+
insufficient: "held-out cells fit no better with any skill axis than with unit means alone (intrinsic dimension 0): the nodes do not differ beyond noise, so no unit separates them"
|
|
3054
|
+
} : !calibration && intrinsic.value === null ? {
|
|
3055
|
+
ranked: [],
|
|
3056
|
+
batch: null,
|
|
3057
|
+
insufficient: "the skill axes are not cross-validated (see the intrinsic dimension), so no unit’s information is known"
|
|
3058
|
+
} : nextUnits(matrix, fit, leaderRows, noiseVariance, calibration?.ridge ?? ridge, options.batch ?? null, link);
|
|
3059
|
+
const nodes = matrix.rows.map((row, index) => ({
|
|
3060
|
+
nodeId: row.nodeId,
|
|
3061
|
+
ordinal: row.ordinal,
|
|
3062
|
+
status: row.status,
|
|
3063
|
+
observed: row.observed.size,
|
|
3064
|
+
coordinates: fit.coordinates[index].map(round9),
|
|
3065
|
+
predictedMean: round9(predicted(fit.coordinates[index])),
|
|
3066
|
+
leader: leaderSet.has(index)
|
|
3067
|
+
}));
|
|
3068
|
+
const units = matrix.unitIds.map((unitId, u) => ({
|
|
3069
|
+
unitId,
|
|
3070
|
+
observedBy: matrix.observedBy[u],
|
|
3071
|
+
bias: round9(link === "identity" ? center + scale * fit.bias[u] : logistic(fit.bias[u])),
|
|
3072
|
+
intercept: round9(fit.bias[u]),
|
|
3073
|
+
loading: fit.loadings[u].map(round9),
|
|
3074
|
+
strength: round9(Math.hypot(...fit.loadings[u]))
|
|
3075
|
+
}));
|
|
3076
|
+
return result(state, link, {
|
|
3077
|
+
split,
|
|
3078
|
+
direction,
|
|
3079
|
+
mode: calibration ? "calibrated" : "fitted",
|
|
3080
|
+
source,
|
|
3081
|
+
matrix: shape,
|
|
3082
|
+
model: {
|
|
3083
|
+
method: METHODS[link].model,
|
|
3084
|
+
link,
|
|
3085
|
+
rank,
|
|
3086
|
+
ridge: calibration?.ridge ?? ridge,
|
|
3087
|
+
center: round9(center),
|
|
3088
|
+
scale: round9(scale),
|
|
3089
|
+
iterations,
|
|
3090
|
+
converged
|
|
3091
|
+
},
|
|
3092
|
+
explained,
|
|
3093
|
+
intrinsicDimension: intrinsic,
|
|
3094
|
+
axes: axesNote(direction),
|
|
3095
|
+
noiseVariance: noiseVariance === null ? null : round9(noiseVariance),
|
|
3096
|
+
nodes,
|
|
3097
|
+
units,
|
|
3098
|
+
leaders: leaderRows.map((index) => ({
|
|
3099
|
+
nodeId: matrix.rows[index].nodeId,
|
|
3100
|
+
predictedMean: round9(predicted(fit.coordinates[index])),
|
|
3101
|
+
observed: matrix.rows[index].observed.size
|
|
3102
|
+
})),
|
|
3103
|
+
nextUnits: next.ranked,
|
|
3104
|
+
batch: options.batch ? next.batch : null,
|
|
3105
|
+
insufficient: next.insufficient
|
|
3106
|
+
});
|
|
3107
|
+
}
|
|
3108
|
+
/** The loadings of a fitted manifold, for a later search over the same units
|
|
3109
|
+
* to place its nodes on. Null unless cross-validation found at least one skill
|
|
3110
|
+
* axis (intrinsic dimension 1 or more), and, for the linear model, measured
|
|
3111
|
+
* the cell noise: loadings of an axis the held-out cells do not support would
|
|
3112
|
+
* steer allocation by noise. */
|
|
3113
|
+
function skillCalibration(lens) {
|
|
3114
|
+
const { data } = lens;
|
|
3115
|
+
const { model } = data;
|
|
3116
|
+
if (data.mode !== "fitted" || model === null || model.center === null || model.scale === null || model.link === "identity" && data.noiseVariance === null || data.intrinsicDimension.value === null || data.intrinsicDimension.value < 1) return null;
|
|
3117
|
+
return {
|
|
3118
|
+
source: data.source,
|
|
3119
|
+
link: model.link,
|
|
3120
|
+
center: model.center,
|
|
3121
|
+
scale: model.scale,
|
|
3122
|
+
rank: model.rank,
|
|
3123
|
+
ridge: model.ridge,
|
|
3124
|
+
noiseVariance: data.noiseVariance,
|
|
3125
|
+
units: data.units.map((unit) => ({
|
|
3126
|
+
unitId: unit.unitId,
|
|
3127
|
+
bias: unit.intercept,
|
|
3128
|
+
loading: [...unit.loading]
|
|
3129
|
+
}))
|
|
3130
|
+
};
|
|
3131
|
+
}
|
|
3132
|
+
function result(state, link, data) {
|
|
3133
|
+
const best = data.nextUnits.filter((unit) => unit.measuredBy < data.leaders.length)[0] ?? data.nextUnits[0] ?? null;
|
|
3134
|
+
const signal = {
|
|
3135
|
+
name: "nextUnit",
|
|
3136
|
+
value: best === null ? null : best.share,
|
|
3137
|
+
subject: best?.unitId ?? null,
|
|
3138
|
+
method: METHODS[link].nextUnit,
|
|
3139
|
+
n: data.leaders.reduce((sum, leader) => sum + leader.observed, 0),
|
|
3140
|
+
insufficient: best === null ? data.insufficient ?? "no unit to rank" : null
|
|
3141
|
+
};
|
|
3142
|
+
return {
|
|
3143
|
+
lens: "skillManifold",
|
|
3144
|
+
searchId: state.searchId,
|
|
3145
|
+
sequence: headSequence(state),
|
|
3146
|
+
data,
|
|
3147
|
+
signal
|
|
3148
|
+
};
|
|
3149
|
+
}
|
|
3150
|
+
function axesNote(direction) {
|
|
3151
|
+
return direction === "maximize" ? "axes are principal (largest share first) and oriented so the mean unit loads positively: a larger coordinate predicts a higher score" : "axes are principal (largest share first) and oriented so the mean unit loads negatively: a larger coordinate predicts a lower (better) score";
|
|
3152
|
+
}
|
|
3153
|
+
/**
|
|
3154
|
+
* Per-unit means of every node on `split`. Without a calibration, a unit is
|
|
3155
|
+
* modelled when 2 or more nodes scored it and a node when it scored 2 or more
|
|
3156
|
+
* modelled units, repeated until both hold. With one, the modelled units are
|
|
3157
|
+
* the calibration's, and a node needs one of them.
|
|
3158
|
+
*/
|
|
3159
|
+
function scoreMatrix(state, split, asOf, calibration) {
|
|
3160
|
+
const raw = [];
|
|
3161
|
+
const allUnits = /* @__PURE__ */ new Set();
|
|
3162
|
+
for (const node of state.nodes()) {
|
|
3163
|
+
const cells = asOf === void 0 ? state.scoredCells(node.nodeId, split) : scoredAsOf(state, node.nodeId, split, asOf);
|
|
3164
|
+
const scores = searchUnitScores(cells);
|
|
3165
|
+
const units = new Map(scores.map((unit) => [unit.unitId, unit.mean]));
|
|
3166
|
+
const counts = new Map(scores.map((unit) => [unit.unitId, {
|
|
3167
|
+
sum: unit.sum,
|
|
3168
|
+
count: unit.count
|
|
3169
|
+
}]));
|
|
3170
|
+
for (const unitId of units.keys()) allUnits.add(unitId);
|
|
3171
|
+
raw.push({
|
|
3172
|
+
nodeId: node.nodeId,
|
|
3173
|
+
ordinal: node.ordinal,
|
|
3174
|
+
status: node.status,
|
|
3175
|
+
units,
|
|
3176
|
+
counts,
|
|
3177
|
+
binary: cells.every((cell) => cell.score === 0 || cell.score === 1)
|
|
3178
|
+
});
|
|
3179
|
+
}
|
|
3180
|
+
let unitIds;
|
|
3181
|
+
let keep;
|
|
3182
|
+
if (calibration) {
|
|
3183
|
+
const calibrated = new Set(calibration.units.map((unit) => unit.unitId));
|
|
3184
|
+
unitIds = calibration.units.map((unit) => unit.unitId);
|
|
3185
|
+
keep = raw.filter((row) => [...row.units.keys()].some((unitId) => calibrated.has(unitId)));
|
|
3186
|
+
} else {
|
|
3187
|
+
let units = new Set(allUnits);
|
|
3188
|
+
keep = raw;
|
|
3189
|
+
for (;;) {
|
|
3190
|
+
const counts = /* @__PURE__ */ new Map();
|
|
3191
|
+
for (const row of keep) for (const unitId of row.units.keys()) if (units.has(unitId)) counts.set(unitId, (counts.get(unitId) ?? 0) + 1);
|
|
3192
|
+
const nextUnits = new Set([...units].filter((unitId) => (counts.get(unitId) ?? 0) >= 2));
|
|
3193
|
+
const nextKeep = keep.filter((row) => [...row.units.keys()].filter((unitId) => nextUnits.has(unitId)).length >= 2);
|
|
3194
|
+
const stable = nextUnits.size === units.size && nextKeep.length === keep.length;
|
|
3195
|
+
units = nextUnits;
|
|
3196
|
+
keep = nextKeep;
|
|
3197
|
+
if (stable) break;
|
|
3198
|
+
}
|
|
3199
|
+
unitIds = [...units].sort(compareCodeUnits);
|
|
3200
|
+
}
|
|
3201
|
+
const unitIndex = new Map(unitIds.map((unitId, index) => [unitId, index]));
|
|
3202
|
+
const observedBy = new Array(unitIds.length).fill(0);
|
|
3203
|
+
let observed = 0;
|
|
3204
|
+
const rows = keep.map((row) => {
|
|
3205
|
+
const map = /* @__PURE__ */ new Map();
|
|
3206
|
+
const counts = /* @__PURE__ */ new Map();
|
|
3207
|
+
for (const [unitId, mean] of row.units) {
|
|
3208
|
+
const index = unitIndex.get(unitId);
|
|
3209
|
+
if (index === void 0) continue;
|
|
3210
|
+
map.set(index, mean);
|
|
3211
|
+
counts.set(index, row.counts.get(unitId));
|
|
3212
|
+
observedBy[index] += 1;
|
|
3213
|
+
observed += 1;
|
|
3214
|
+
}
|
|
3215
|
+
return {
|
|
3216
|
+
nodeId: row.nodeId,
|
|
3217
|
+
ordinal: row.ordinal,
|
|
3218
|
+
status: row.status,
|
|
3219
|
+
observed: map,
|
|
3220
|
+
counts
|
|
3221
|
+
};
|
|
3222
|
+
});
|
|
3223
|
+
return {
|
|
3224
|
+
rows,
|
|
3225
|
+
unitIds,
|
|
3226
|
+
observedBy,
|
|
3227
|
+
observed,
|
|
3228
|
+
binary: keep.length > 0 && keep.every((row) => row.binary),
|
|
3229
|
+
excludedNodes: raw.length - rows.length,
|
|
3230
|
+
excludedUnits: calibration ? [...allUnits].filter((unitId) => !unitIndex.has(unitId)).length : allUnits.size - unitIds.length
|
|
3231
|
+
};
|
|
3232
|
+
}
|
|
3233
|
+
/** A node's scored cells on `split` whose score the ledger held at `asOf`:
|
|
3234
|
+
* a cell's last update is its scoring settle, so a later retry is excluded. */
|
|
3235
|
+
function scoredAsOf(state, nodeId, split, asOf) {
|
|
3236
|
+
const cells = [];
|
|
3237
|
+
for (const cell of state.cells({ nodeId })) {
|
|
3238
|
+
if (cell.split !== split || cell.score === null || cell.updatedSequence > asOf) continue;
|
|
3239
|
+
cells.push({
|
|
3240
|
+
cellId: cell.cellId,
|
|
3241
|
+
unitId: cell.unitId,
|
|
3242
|
+
attempt: cell.attempts,
|
|
3243
|
+
score: cell.score
|
|
3244
|
+
});
|
|
3245
|
+
}
|
|
3246
|
+
return cells;
|
|
3247
|
+
}
|
|
3248
|
+
function standardization(matrix) {
|
|
3249
|
+
let total = 0;
|
|
3250
|
+
const columnSum = new Array(matrix.unitIds.length).fill(0);
|
|
3251
|
+
for (const row of matrix.rows) for (const [u, value] of row.observed) {
|
|
3252
|
+
total += value;
|
|
3253
|
+
columnSum[u] += value;
|
|
3254
|
+
}
|
|
3255
|
+
const center = total / matrix.observed;
|
|
3256
|
+
let squares = 0;
|
|
3257
|
+
for (const row of matrix.rows) for (const [u, value] of row.observed) squares += (value - columnSum[u] / matrix.observedBy[u]) ** 2;
|
|
3258
|
+
const scale = Math.sqrt(squares / matrix.observed);
|
|
3259
|
+
return scale > 1e-12 ? {
|
|
3260
|
+
center,
|
|
3261
|
+
scale
|
|
3262
|
+
} : null;
|
|
3263
|
+
}
|
|
3264
|
+
function observedCells(matrix, rows, center, scale) {
|
|
3265
|
+
const cells = [];
|
|
3266
|
+
for (const row of rows) {
|
|
3267
|
+
const { nodeId, observed, counts } = matrix.rows[row];
|
|
3268
|
+
for (const [unit, value] of [...observed].sort(([left], [right]) => left - right)) {
|
|
3269
|
+
const { sum, count } = counts.get(unit);
|
|
3270
|
+
cells.push({
|
|
3271
|
+
row,
|
|
3272
|
+
unit,
|
|
3273
|
+
z: (value - center) / scale,
|
|
3274
|
+
k: sum,
|
|
3275
|
+
n: count,
|
|
3276
|
+
fold: fnv1a(`${nodeId}\u0000${matrix.unitIds[unit]}`)
|
|
3277
|
+
});
|
|
3278
|
+
}
|
|
3279
|
+
}
|
|
3280
|
+
return cells;
|
|
3281
|
+
}
|
|
3282
|
+
/** 32-bit FNV-1a: a deterministic fold assignment that depends on the cell's
|
|
3283
|
+
* coordinates alone, not on the order cells were read. */
|
|
3284
|
+
function fnv1a(text) {
|
|
3285
|
+
let hash = 2166136261;
|
|
3286
|
+
for (let index = 0; index < text.length; index++) {
|
|
3287
|
+
hash ^= text.charCodeAt(index);
|
|
3288
|
+
hash = Math.imul(hash, 16777619);
|
|
3289
|
+
}
|
|
3290
|
+
return hash >>> 0;
|
|
3291
|
+
}
|
|
3292
|
+
/** The fit of `rank` axes under `link`. */
|
|
3293
|
+
function factor(units, cells, rank, ridge, link) {
|
|
3294
|
+
return link === "identity" ? factorLinear(units, cells, rank, ridge) : factorLogistic(units, cells, rank, ridge);
|
|
3295
|
+
}
|
|
3296
|
+
function groupCells(units, cells) {
|
|
3297
|
+
const byUnit = Array.from({ length: units }, () => []);
|
|
3298
|
+
const byRow = /* @__PURE__ */ new Map();
|
|
3299
|
+
for (const cell of cells) {
|
|
3300
|
+
byUnit[cell.unit].push(cell);
|
|
3301
|
+
const list = byRow.get(cell.row);
|
|
3302
|
+
if (list) list.push(cell);
|
|
3303
|
+
else byRow.set(cell.row, [cell]);
|
|
3304
|
+
}
|
|
3305
|
+
return {
|
|
3306
|
+
byUnit,
|
|
3307
|
+
byRow
|
|
3308
|
+
};
|
|
3309
|
+
}
|
|
3310
|
+
/** Loadings start from a fixed seeded draw, so a fit is deterministic. */
|
|
3311
|
+
function seededLoadings(units, rank) {
|
|
3312
|
+
const random = mulberry32(24301 + rank);
|
|
3313
|
+
return Array.from({ length: units }, () => Array.from({ length: rank }, () => (random() - .5) * .2));
|
|
3314
|
+
}
|
|
3315
|
+
/**
|
|
3316
|
+
* Alternating ridge least squares over `cells`: rows' coordinates given the
|
|
3317
|
+
* loadings, then each unit's loading and (unpenalized) bias given the
|
|
3318
|
+
* coordinates, until the objective moves by less than 1e-7 of itself.
|
|
3319
|
+
*/
|
|
3320
|
+
function factorLinear(units, cells, rank, ridge) {
|
|
3321
|
+
const { byUnit, byRow: rowIds } = groupCells(units, cells);
|
|
3322
|
+
const bias = byUnit.map((list) => list.length === 0 ? 0 : list.reduce((sum, cell) => sum + cell.z, 0) / list.length);
|
|
3323
|
+
const loadings = seededLoadings(units, rank);
|
|
3324
|
+
const coordinates = /* @__PURE__ */ new Map();
|
|
3325
|
+
for (const row of rowIds.keys()) coordinates.set(row, new Array(rank).fill(0));
|
|
3326
|
+
if (rank === 0) return {
|
|
3327
|
+
rank,
|
|
3328
|
+
bias,
|
|
3329
|
+
loadings,
|
|
3330
|
+
byRow: coordinates,
|
|
3331
|
+
coordinates: [],
|
|
3332
|
+
iterations: 0,
|
|
3333
|
+
converged: true
|
|
3334
|
+
};
|
|
3335
|
+
let previous = Number.POSITIVE_INFINITY;
|
|
3336
|
+
let iterations = 0;
|
|
3337
|
+
let converged = false;
|
|
3338
|
+
for (; iterations < MAX_ITERATIONS; iterations++) {
|
|
3339
|
+
for (const [row, list] of rowIds) coordinates.set(row, solveRow(list, loadings, bias, rank, ridge, "identity"));
|
|
3340
|
+
for (let u = 0; u < units; u++) {
|
|
3341
|
+
const list = byUnit[u];
|
|
3342
|
+
if (list.length === 0) continue;
|
|
3343
|
+
const size = rank + 1;
|
|
3344
|
+
const system = Array.from({ length: size }, (_, i) => Array.from({ length: size }, (_, j) => i === j && i < rank ? ridge : 0));
|
|
3345
|
+
const rhs = new Array(size).fill(0);
|
|
3346
|
+
for (const cell of list) {
|
|
3347
|
+
const point = coordinates.get(cell.row);
|
|
3348
|
+
for (let i = 0; i < size; i++) {
|
|
3349
|
+
const xi = i < rank ? point[i] : 1;
|
|
3350
|
+
rhs[i] += xi * cell.z;
|
|
3351
|
+
const line = system[i];
|
|
3352
|
+
for (let j = 0; j < size; j++) line[j] += xi * (j < rank ? point[j] : 1);
|
|
3353
|
+
}
|
|
3354
|
+
}
|
|
3355
|
+
const lower = cholesky(system);
|
|
3356
|
+
if (lower === null) continue;
|
|
3357
|
+
const solution = choleskySolve(lower, rhs);
|
|
3358
|
+
loadings[u] = solution.slice(0, rank);
|
|
3359
|
+
bias[u] = solution[rank];
|
|
3360
|
+
}
|
|
3361
|
+
let objective = 0;
|
|
3362
|
+
for (const cell of cells) objective += (cell.z - bias[cell.unit] - dot(coordinates.get(cell.row), loadings[cell.unit])) ** 2;
|
|
3363
|
+
for (const point of coordinates.values()) objective += ridge * dot(point, point);
|
|
3364
|
+
for (const loading of loadings) objective += ridge * dot(loading, loading);
|
|
3365
|
+
if (Math.abs(previous - objective) <= TOLERANCE * Math.max(objective, 1e-12)) {
|
|
3366
|
+
converged = true;
|
|
3367
|
+
iterations += 1;
|
|
3368
|
+
break;
|
|
3369
|
+
}
|
|
3370
|
+
previous = objective;
|
|
3371
|
+
}
|
|
3372
|
+
return {
|
|
3373
|
+
rank,
|
|
3374
|
+
bias,
|
|
3375
|
+
loadings,
|
|
3376
|
+
byRow: coordinates,
|
|
3377
|
+
coordinates: [],
|
|
3378
|
+
iterations,
|
|
3379
|
+
converged
|
|
3380
|
+
};
|
|
3381
|
+
}
|
|
3382
|
+
/**
|
|
3383
|
+
* The item-response fit: alternating Newton steps on the penalized binomial
|
|
3384
|
+
* likelihood, each row's coordinates given the loadings, then each unit's
|
|
3385
|
+
* loading and intercept given the coordinates, until the objective moves by
|
|
3386
|
+
* less than 1e-7 of itself. Priors: standard normal on coordinates and
|
|
3387
|
+
* loadings (scaled by `ridge`), N(0, 10²) on each intercept, which keeps a
|
|
3388
|
+
* unit every node passed finite. A step moves no parameter by more than 4.
|
|
3389
|
+
*/
|
|
3390
|
+
function factorLogistic(units, cells, rank, ridge) {
|
|
3391
|
+
const { byUnit, byRow: rowIds } = groupCells(units, cells);
|
|
3392
|
+
const bias = byUnit.map((list) => {
|
|
3393
|
+
let k = 0;
|
|
3394
|
+
let n = 0;
|
|
3395
|
+
for (const cell of list) {
|
|
3396
|
+
k += cell.k;
|
|
3397
|
+
n += cell.n;
|
|
3398
|
+
}
|
|
3399
|
+
return Math.log((k + .5) / (n - k + .5));
|
|
3400
|
+
});
|
|
3401
|
+
const loadings = seededLoadings(units, rank);
|
|
3402
|
+
const coordinates = /* @__PURE__ */ new Map();
|
|
3403
|
+
for (const row of rowIds.keys()) coordinates.set(row, new Array(rank).fill(0));
|
|
3404
|
+
const objectiveOf = () => {
|
|
3405
|
+
let total = 0;
|
|
3406
|
+
for (const cell of cells) total += binomialLoss(cell, bias[cell.unit] + dot(coordinates.get(cell.row), loadings[cell.unit]));
|
|
3407
|
+
for (const point of coordinates.values()) total += ridge / 2 * dot(point, point);
|
|
3408
|
+
for (const loading of loadings) total += ridge / 2 * dot(loading, loading);
|
|
3409
|
+
for (const value of bias) total += LOGIT_BIAS_PRECISION / 2 * value * value;
|
|
3410
|
+
return total;
|
|
3411
|
+
};
|
|
3412
|
+
let previous = Number.POSITIVE_INFINITY;
|
|
3413
|
+
let iterations = 0;
|
|
3414
|
+
let converged = false;
|
|
3415
|
+
for (; iterations < MAX_ITERATIONS; iterations++) {
|
|
3416
|
+
if (rank > 0) for (const [row, list] of rowIds) {
|
|
3417
|
+
const point = coordinates.get(row);
|
|
3418
|
+
coordinates.set(row, newtonStep(point, list, loadings, bias, ridge));
|
|
3419
|
+
}
|
|
3420
|
+
for (let u = 0; u < units; u++) {
|
|
3421
|
+
const list = byUnit[u];
|
|
3422
|
+
if (list.length === 0) continue;
|
|
3423
|
+
const size = rank + 1;
|
|
3424
|
+
const theta = [...loadings[u], bias[u]];
|
|
3425
|
+
const precision = (i) => i < rank ? ridge : LOGIT_BIAS_PRECISION;
|
|
3426
|
+
const hessian = Array.from({ length: size }, (_, i) => Array.from({ length: size }, (_, j) => i === j ? precision(i) : 0));
|
|
3427
|
+
const gradient = theta.map((value, i) => -precision(i) * value);
|
|
3428
|
+
for (const cell of list) {
|
|
3429
|
+
const point = coordinates.get(cell.row);
|
|
3430
|
+
const p = logistic(theta[rank] + dot(point, theta));
|
|
3431
|
+
const weight = cell.n * p * (1 - p);
|
|
3432
|
+
for (let i = 0; i < size; i++) {
|
|
3433
|
+
const xi = i < rank ? point[i] : 1;
|
|
3434
|
+
gradient[i] += (cell.k - cell.n * p) * xi;
|
|
3435
|
+
const line = hessian[i];
|
|
3436
|
+
for (let j = 0; j < size; j++) line[j] += weight * xi * (j < rank ? point[j] : 1);
|
|
3437
|
+
}
|
|
3438
|
+
}
|
|
3439
|
+
const lower = cholesky(hessian);
|
|
3440
|
+
if (lower === null) continue;
|
|
3441
|
+
const step = capped(choleskySolve(lower, gradient));
|
|
3442
|
+
loadings[u] = theta.slice(0, rank).map((value, i) => value + step[i]);
|
|
3443
|
+
bias[u] = theta[rank] + step[rank];
|
|
3444
|
+
}
|
|
3445
|
+
const objective = objectiveOf();
|
|
3446
|
+
if (Math.abs(previous - objective) <= TOLERANCE * Math.max(objective, 1e-12)) {
|
|
3447
|
+
converged = true;
|
|
3448
|
+
iterations += 1;
|
|
3449
|
+
break;
|
|
3450
|
+
}
|
|
3451
|
+
previous = objective;
|
|
3452
|
+
}
|
|
3453
|
+
return {
|
|
3454
|
+
rank,
|
|
3455
|
+
bias,
|
|
3456
|
+
loadings,
|
|
3457
|
+
byRow: coordinates,
|
|
3458
|
+
coordinates: [],
|
|
3459
|
+
iterations,
|
|
3460
|
+
converged
|
|
3461
|
+
};
|
|
3462
|
+
}
|
|
3463
|
+
/** One Newton step on a row's coordinates under the item-response model. */
|
|
3464
|
+
function newtonStep(point, list, loadings, bias, ridge) {
|
|
3465
|
+
const rank = point.length;
|
|
3466
|
+
const hessian = Array.from({ length: rank }, (_, i) => Array.from({ length: rank }, (_, j) => i === j ? ridge : 0));
|
|
3467
|
+
const gradient = point.map((value) => -ridge * value);
|
|
3468
|
+
for (const cell of list) {
|
|
3469
|
+
const q = loadings[cell.unit];
|
|
3470
|
+
const p = logistic(bias[cell.unit] + dot(point, q));
|
|
3471
|
+
const weight = cell.n * p * (1 - p);
|
|
3472
|
+
for (let i = 0; i < rank; i++) {
|
|
3473
|
+
gradient[i] += (cell.k - cell.n * p) * q[i];
|
|
3474
|
+
for (let j = 0; j < rank; j++) hessian[i][j] += weight * q[i] * q[j];
|
|
3475
|
+
}
|
|
3476
|
+
}
|
|
3477
|
+
const step = capped(choleskySolve(cholesky(hessian), gradient));
|
|
3478
|
+
return point.map((value, i) => value + step[i]);
|
|
3479
|
+
}
|
|
3480
|
+
function capped(step) {
|
|
3481
|
+
const largest = Math.max(0, ...step.map(Math.abs));
|
|
3482
|
+
return largest > NEWTON_STEP_CAP ? step.map((value) => value * NEWTON_STEP_CAP / largest) : [...step];
|
|
3483
|
+
}
|
|
3484
|
+
/** −log of the binomial likelihood of `k` passes in `n` at log-odds `eta`,
|
|
3485
|
+
* without the binomial coefficient: n·log(1 + e^η) − k·η, computed stably. */
|
|
3486
|
+
function binomialLoss(cell, eta) {
|
|
3487
|
+
const softplus = Math.max(eta, 0) + Math.log1p(Math.exp(-Math.abs(eta)));
|
|
3488
|
+
return cell.n * softplus - cell.k * eta;
|
|
3489
|
+
}
|
|
3490
|
+
function logistic(eta) {
|
|
3491
|
+
if (eta >= 0) return 1 / (1 + Math.exp(-eta));
|
|
3492
|
+
const e = Math.exp(eta);
|
|
3493
|
+
return e / (1 + e);
|
|
3494
|
+
}
|
|
3495
|
+
/** A row's coordinates given the loadings: one ridge solve (identity), or
|
|
3496
|
+
* Newton steps to convergence (logit). */
|
|
3497
|
+
function solveRow(list, loadings, bias, rank, ridge, link) {
|
|
3498
|
+
if (link === "logit") {
|
|
3499
|
+
let point = new Array(rank).fill(0);
|
|
3500
|
+
for (let iteration = 0; iteration < 50; iteration++) {
|
|
3501
|
+
const next = newtonStep(point, list, loadings, bias, ridge);
|
|
3502
|
+
const moved = Math.max(0, ...next.map((value, i) => Math.abs(value - point[i])));
|
|
3503
|
+
point = next;
|
|
3504
|
+
if (moved < 1e-10) break;
|
|
3505
|
+
}
|
|
3506
|
+
return point;
|
|
3507
|
+
}
|
|
3508
|
+
const system = Array.from({ length: rank }, (_, i) => Array.from({ length: rank }, (_, j) => i === j ? ridge : 0));
|
|
3509
|
+
const rhs = new Array(rank).fill(0);
|
|
3510
|
+
for (const cell of list) {
|
|
3511
|
+
const q = loadings[cell.unit];
|
|
3512
|
+
const residual = cell.z - bias[cell.unit];
|
|
3513
|
+
for (let i = 0; i < rank; i++) {
|
|
3514
|
+
rhs[i] += q[i] * residual;
|
|
3515
|
+
for (let j = 0; j < rank; j++) system[i][j] += q[i] * q[j];
|
|
3516
|
+
}
|
|
3517
|
+
}
|
|
3518
|
+
return choleskySolve(cholesky(system), rhs);
|
|
3519
|
+
}
|
|
3520
|
+
/** The model's linear predictor: standardized mean (identity) or log-odds (logit). */
|
|
3521
|
+
function predict(fit, point, unit) {
|
|
3522
|
+
return fit.bias[unit] + (point ? dot(point, fit.loadings[unit]) : 0);
|
|
3523
|
+
}
|
|
3524
|
+
/** Squared error (identity) or binomial log loss (logit) of the fit on `cells`. */
|
|
3525
|
+
function loss(fit, cells, link) {
|
|
3526
|
+
let total = 0;
|
|
3527
|
+
for (const cell of cells) total += cellLoss(cell, predict(fit, fit.byRow.get(cell.row), cell.unit), link);
|
|
3528
|
+
return total;
|
|
3529
|
+
}
|
|
3530
|
+
function cellLoss(cell, eta, link) {
|
|
3531
|
+
return link === "identity" ? (cell.z - eta) ** 2 : binomialLoss(cell, eta);
|
|
3532
|
+
}
|
|
3533
|
+
/** Every matrix row placed on the fitted loadings. */
|
|
3534
|
+
function placeRows(matrix, fit, center, scale, ridge, link) {
|
|
3535
|
+
fit.coordinates = matrix.rows.map((row) => {
|
|
3536
|
+
if (fit.rank === 0) return [];
|
|
3537
|
+
return solveRow([...row.observed].map(([unit, value]) => ({
|
|
3538
|
+
unit,
|
|
3539
|
+
z: (value - center) / scale,
|
|
3540
|
+
k: row.counts.get(unit).sum,
|
|
3541
|
+
n: row.counts.get(unit).count
|
|
3542
|
+
})), fit.loadings, fit.bias, fit.rank, ridge, link);
|
|
3543
|
+
});
|
|
3544
|
+
}
|
|
3545
|
+
/**
|
|
3546
|
+
* Rotates the fitted rows and loadings to principal axes: the singular
|
|
3547
|
+
* vectors of the fitted low-rank matrix, largest first, with the scale split
|
|
3548
|
+
* evenly between rows and loadings. The product the model predicts is
|
|
3549
|
+
* unchanged. Returns each axis's share of the squared singular values.
|
|
3550
|
+
*/
|
|
3551
|
+
function principalAxes(fit, fitRows) {
|
|
3552
|
+
const rank = fit.rank;
|
|
3553
|
+
const byRow = fit.byRow;
|
|
3554
|
+
const rows = fitRows.map((row) => byRow.get(row) ?? new Array(rank).fill(0));
|
|
3555
|
+
const gram = (vectors) => Array.from({ length: rank }, (_, i) => Array.from({ length: rank }, (_, j) => vectors.reduce((sum, v) => sum + v[i] * v[j], 0)));
|
|
3556
|
+
const lowerP = cholesky(gram(rows));
|
|
3557
|
+
const lowerQ = cholesky(gram(fit.loadings));
|
|
3558
|
+
if (lowerP === null || lowerQ === null) return [];
|
|
3559
|
+
const c = Array.from({ length: rank }, (_, i) => Array.from({ length: rank }, (_, j) => {
|
|
3560
|
+
let sum = 0;
|
|
3561
|
+
for (let m = 0; m < rank; m++) sum += lowerP[m][i] * lowerQ[m][j];
|
|
3562
|
+
return sum;
|
|
3563
|
+
}));
|
|
3564
|
+
const { values, vectors } = symmetricEigen(Array.from({ length: rank }, (_, i) => Array.from({ length: rank }, (_, j) => {
|
|
3565
|
+
let sum = 0;
|
|
3566
|
+
for (let m = 0; m < rank; m++) sum += c[m][i] * c[m][j];
|
|
3567
|
+
return sum;
|
|
3568
|
+
})));
|
|
3569
|
+
const sigma = values.map((value) => Math.sqrt(Math.max(value, 0)));
|
|
3570
|
+
if (sigma.some((value) => value <= 1e-12)) return [];
|
|
3571
|
+
const b = Array.from({ length: rank }, (_, i) => vectors.map((vector) => vector[i]));
|
|
3572
|
+
const a = Array.from({ length: rank }, (_, i) => Array.from({ length: rank }, (_, j) => {
|
|
3573
|
+
let sum = 0;
|
|
3574
|
+
for (let m = 0; m < rank; m++) sum += c[i][m] * b[m][j];
|
|
3575
|
+
return sum / sigma[j];
|
|
3576
|
+
}));
|
|
3577
|
+
const upperInverse = (lower) => transpose(lowerInverse(lower));
|
|
3578
|
+
const transformP = multiply(multiply(upperInverse(lowerP), a), diagonal(sigma.map(Math.sqrt)));
|
|
3579
|
+
const transformQ = multiply(multiply(upperInverse(lowerQ), b), diagonal(sigma.map(Math.sqrt)));
|
|
3580
|
+
for (const [row, point] of byRow) byRow.set(row, applyRow(point, transformP));
|
|
3581
|
+
fit.loadings = fit.loadings.map((loading) => applyRow(loading, transformQ));
|
|
3582
|
+
const total = values.reduce((sum, value) => sum + Math.max(value, 0), 0);
|
|
3583
|
+
return values.map((value) => Math.max(value, 0) / total);
|
|
3584
|
+
}
|
|
3585
|
+
/** Flips each axis so the mean loading points toward a better score. */
|
|
3586
|
+
function orientAxes(fit, sign) {
|
|
3587
|
+
for (let d = 0; d < fit.rank; d++) {
|
|
3588
|
+
let mean = 0;
|
|
3589
|
+
for (const loading of fit.loadings) mean += loading[d];
|
|
3590
|
+
if (sign * mean >= 0) continue;
|
|
3591
|
+
for (const loading of fit.loadings) loading[d] = -loading[d] || 0;
|
|
3592
|
+
for (const point of fit.coordinates) point[d] = -point[d] || 0;
|
|
3593
|
+
for (const point of fit.byRow.values()) point[d] = -point[d] || 0;
|
|
3594
|
+
}
|
|
3595
|
+
}
|
|
3596
|
+
function crossValidate(matrix, cells, rankCap, folds, ridge, link) {
|
|
3597
|
+
const rows = new Set(cells.map((cell) => cell.row)).size;
|
|
3598
|
+
if (rows < INSUFFICIENT_FROM || matrix.unitIds.length < INSUFFICIENT_FROM) return {
|
|
3599
|
+
value: null,
|
|
3600
|
+
method: METHODS[link].intrinsic,
|
|
3601
|
+
n: 0,
|
|
3602
|
+
folds,
|
|
3603
|
+
curve: [],
|
|
3604
|
+
insufficient: `cross-validation needs ${INSUFFICIENT_FROM} nodes and ${INSUFFICIENT_FROM} units; ${plural$1(rows, "node")} and ${plural$1(matrix.unitIds.length, "unit")} qualify`
|
|
3605
|
+
};
|
|
3606
|
+
const curve = [];
|
|
3607
|
+
let held = 0;
|
|
3608
|
+
for (let rank = 0; rank <= rankCap; rank++) {
|
|
3609
|
+
const foldErrors = [];
|
|
3610
|
+
let squares = 0;
|
|
3611
|
+
let count = 0;
|
|
3612
|
+
for (let fold = 0; fold < folds; fold++) {
|
|
3613
|
+
const train = cells.filter((cell) => cell.fold % folds !== fold);
|
|
3614
|
+
const test = cells.filter((cell) => cell.fold % folds === fold);
|
|
3615
|
+
const fit = factor(matrix.unitIds.length, train, rank, ridge, link);
|
|
3616
|
+
const trained = new Set(train.map((cell) => cell.unit));
|
|
3617
|
+
let foldSquares = 0;
|
|
3618
|
+
let foldCount = 0;
|
|
3619
|
+
for (const cell of test) {
|
|
3620
|
+
if (!trained.has(cell.unit)) continue;
|
|
3621
|
+
foldSquares += cellLoss(cell, predict(fit, fit.byRow.get(cell.row), cell.unit), link);
|
|
3622
|
+
foldCount += 1;
|
|
3623
|
+
}
|
|
3624
|
+
if (foldCount > 0) foldErrors.push(foldSquares / foldCount);
|
|
3625
|
+
squares += foldSquares;
|
|
3626
|
+
count += foldCount;
|
|
3627
|
+
}
|
|
3628
|
+
held = count;
|
|
3629
|
+
const error = count > 0 ? squares / count : NaN;
|
|
3630
|
+
const spread = foldErrors.length > 1 ? Math.sqrt(foldErrors.reduce((sum, value) => sum + (value - mean(foldErrors)) ** 2, 0) / (foldErrors.length - 1) / foldErrors.length) : NaN;
|
|
3631
|
+
curve.push({
|
|
3632
|
+
rank,
|
|
3633
|
+
error: round9(error),
|
|
3634
|
+
standardError: round9(spread)
|
|
3635
|
+
});
|
|
3636
|
+
}
|
|
3637
|
+
const finite = curve.filter((point) => Number.isFinite(point.error));
|
|
3638
|
+
if (finite.length === 0) return {
|
|
3639
|
+
value: null,
|
|
3640
|
+
method: METHODS[link].intrinsic,
|
|
3641
|
+
n: held,
|
|
3642
|
+
folds,
|
|
3643
|
+
curve,
|
|
3644
|
+
insufficient: "no held-out cell had a unit the rest of the data measured"
|
|
3645
|
+
};
|
|
3646
|
+
const best = finite.reduce((left, right) => right.error < left.error ? right : left);
|
|
3647
|
+
const bar = best.error + (Number.isFinite(best.standardError) ? best.standardError : 0);
|
|
3648
|
+
return {
|
|
3649
|
+
value: finite.find((point) => point.error <= bar).rank,
|
|
3650
|
+
method: METHODS[link].intrinsic,
|
|
3651
|
+
n: held,
|
|
3652
|
+
folds,
|
|
3653
|
+
curve,
|
|
3654
|
+
insufficient: null
|
|
3655
|
+
};
|
|
3656
|
+
}
|
|
3657
|
+
function nextUnits(matrix, fit, leaderRows, noiseVariance, ridge, batch, link) {
|
|
3658
|
+
if (leaderRows.length < 2) return {
|
|
3659
|
+
ranked: [],
|
|
3660
|
+
batch: null,
|
|
3661
|
+
insufficient: `${plural$1(leaderRows.length, "leader")} qualify (${INSUFFICIENT_FROM} or more modelled units); separating leaders needs 2`
|
|
3662
|
+
};
|
|
3663
|
+
if (link === "identity" && (noiseVariance === null || !(noiseVariance > 0))) return {
|
|
3664
|
+
ranked: [],
|
|
3665
|
+
batch: null,
|
|
3666
|
+
insufficient: "the cell noise is unmeasured, so no unit’s information is known"
|
|
3667
|
+
};
|
|
3668
|
+
if (fit.rank === 0) return {
|
|
3669
|
+
ranked: [],
|
|
3670
|
+
batch: null,
|
|
3671
|
+
insufficient: "rank 0: the nodes do not differ beyond noise"
|
|
3672
|
+
};
|
|
3673
|
+
const rank = fit.rank;
|
|
3674
|
+
const units = matrix.unitIds.length;
|
|
3675
|
+
const meanLoading = new Array(rank).fill(0);
|
|
3676
|
+
for (const loading of fit.loadings) for (let d = 0; d < rank; d++) meanLoading[d] += loading[d] / units;
|
|
3677
|
+
const leaders = leaderRows.map((row) => {
|
|
3678
|
+
const point = fit.coordinates[row];
|
|
3679
|
+
const information = fit.loadings.map((q, u) => {
|
|
3680
|
+
if (link === "identity") return 1 / noiseVariance;
|
|
3681
|
+
const p = logistic(fit.bias[u] + dot(point, q));
|
|
3682
|
+
return p * (1 - p);
|
|
3683
|
+
});
|
|
3684
|
+
const gradient = link === "identity" ? meanLoading : Array.from({ length: rank }, (_, d) => {
|
|
3685
|
+
let total = 0;
|
|
3686
|
+
for (let u = 0; u < units; u++) total += information[u] * fit.loadings[u][d] / units;
|
|
3687
|
+
return total;
|
|
3688
|
+
});
|
|
3689
|
+
const prior = link === "identity" ? ridge / noiseVariance : ridge;
|
|
3690
|
+
const system = Array.from({ length: rank }, (_, i) => Array.from({ length: rank }, (_, j) => i === j ? prior : 0));
|
|
3691
|
+
const counts = matrix.rows[row].counts;
|
|
3692
|
+
for (const unit of matrix.rows[row].observed.keys()) {
|
|
3693
|
+
const q = fit.loadings[unit];
|
|
3694
|
+
const weight = information[unit] * (link === "identity" ? 1 : counts.get(unit).count);
|
|
3695
|
+
for (let i = 0; i < rank; i++) for (let j = 0; j < rank; j++) system[i][j] += weight * q[i] * q[j];
|
|
3696
|
+
}
|
|
3697
|
+
return {
|
|
3698
|
+
information,
|
|
3699
|
+
gradient,
|
|
3700
|
+
covariance: choleskyInverse(cholesky(system))
|
|
3701
|
+
};
|
|
3702
|
+
});
|
|
3703
|
+
const shares = (sigmas) => {
|
|
3704
|
+
const toward = sigmas.map((sigma, index) => sigma.map((line) => dot(line, leaders[index].gradient)));
|
|
3705
|
+
const contrast = toward.map((vector, index) => dot(leaders[index].gradient, vector));
|
|
3706
|
+
return fit.loadings.map((q, u) => {
|
|
3707
|
+
const reduction = sigmas.map((sigma, index) => {
|
|
3708
|
+
const information = leaders[index].information[u];
|
|
3709
|
+
if (!(information > 0)) return 0;
|
|
3710
|
+
const spread = dot(q, sigma.map((line) => dot(line, q)));
|
|
3711
|
+
return dot(toward[index], q) ** 2 / (1 / information + spread);
|
|
3712
|
+
});
|
|
3713
|
+
let share = 0;
|
|
3714
|
+
let pairs = 0;
|
|
3715
|
+
for (let i = 0; i < sigmas.length; i++) for (let j = i + 1; j < sigmas.length; j++) {
|
|
3716
|
+
const variance = contrast[i] + contrast[j];
|
|
3717
|
+
if (variance > 0) share += (reduction[i] + reduction[j]) / variance;
|
|
3718
|
+
pairs += 1;
|
|
3719
|
+
}
|
|
3720
|
+
return share / pairs;
|
|
3721
|
+
});
|
|
3722
|
+
};
|
|
3723
|
+
const byShare = (values) => (left, right) => values[right] - values[left] || compareCodeUnits(matrix.unitIds[left], matrix.unitIds[right]);
|
|
3724
|
+
const covariance = leaders.map((leader) => leader.covariance);
|
|
3725
|
+
const first = shares(covariance);
|
|
3726
|
+
const ranked = matrix.unitIds.map((_, u) => u).sort(byShare(first)).map((u) => ({
|
|
3727
|
+
unitId: matrix.unitIds[u],
|
|
3728
|
+
share: round9(first[u]),
|
|
3729
|
+
measuredBy: leaderRows.filter((row) => matrix.rows[row].observed.has(u)).length
|
|
3730
|
+
}));
|
|
3731
|
+
if (batch === null) return {
|
|
3732
|
+
ranked,
|
|
3733
|
+
batch: null,
|
|
3734
|
+
insufficient: null
|
|
3735
|
+
};
|
|
3736
|
+
const among = new Set(batch.among ?? matrix.unitIds);
|
|
3737
|
+
const open = new Set(matrix.unitIds.flatMap((unitId, u) => among.has(unitId) ? [u] : []));
|
|
3738
|
+
const picked = [];
|
|
3739
|
+
let sigmas = covariance;
|
|
3740
|
+
while (picked.length < batch.count && open.size > 0) {
|
|
3741
|
+
const values = shares(sigmas);
|
|
3742
|
+
const best = [...open].sort(byShare(values))[0];
|
|
3743
|
+
open.delete(best);
|
|
3744
|
+
picked.push(matrix.unitIds[best]);
|
|
3745
|
+
const q = fit.loadings[best];
|
|
3746
|
+
sigmas = sigmas.map((sigma, index) => {
|
|
3747
|
+
const information = leaders[index].information[best];
|
|
3748
|
+
if (!(information > 0)) return sigma;
|
|
3749
|
+
const toward = sigma.map((line) => dot(line, q));
|
|
3750
|
+
const denominator = 1 / information + dot(q, toward);
|
|
3751
|
+
return sigma.map((line, i) => line.map((value, j) => value - toward[i] * toward[j] / denominator));
|
|
3752
|
+
});
|
|
3753
|
+
}
|
|
3754
|
+
return {
|
|
3755
|
+
ranked,
|
|
3756
|
+
batch: picked,
|
|
3757
|
+
insufficient: null
|
|
3758
|
+
};
|
|
3759
|
+
}
|
|
3760
|
+
/**
|
|
3761
|
+
* The `asha` extension that adds, at each rung, the units that best separate
|
|
3762
|
+
* the leaders: the greedy `nextUnit` batch from `calibration`'s loadings,
|
|
3763
|
+
* with this search's nodes placed on them from the cells the ledger held when
|
|
3764
|
+
* the rung opened. The leaders are the lens's (the top 3 by predicted mean on
|
|
3765
|
+
* every calibrated unit). Where the lens cannot rank (fewer than 2 leaders,
|
|
3766
|
+
* or no measured noise), `asha` fills the rung from its seeded order.
|
|
3767
|
+
*/
|
|
3768
|
+
function nextUnitExtension(calibration, options = {}) {
|
|
3769
|
+
return {
|
|
3770
|
+
name: `next-unit(${hashCanonical(calibration).slice(7, 19)})`,
|
|
3771
|
+
choose({ state, asOfSequence, split, remaining, count }) {
|
|
3772
|
+
return skillManifold(state, calibration.rank, {
|
|
3773
|
+
split,
|
|
3774
|
+
calibration,
|
|
3775
|
+
asOfSequence,
|
|
3776
|
+
...options.leaders === void 0 ? {} : { leaders: options.leaders },
|
|
3777
|
+
batch: {
|
|
3778
|
+
count,
|
|
3779
|
+
among: remaining
|
|
3780
|
+
}
|
|
3781
|
+
}).data.batch ?? [];
|
|
3782
|
+
}
|
|
3783
|
+
};
|
|
3784
|
+
}
|
|
3785
|
+
function dot(left, right) {
|
|
3786
|
+
let sum = 0;
|
|
3787
|
+
for (let index = 0; index < left.length; index++) sum += left[index] * right[index];
|
|
3788
|
+
return sum;
|
|
3789
|
+
}
|
|
3790
|
+
function mean(values) {
|
|
3791
|
+
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
3792
|
+
}
|
|
3793
|
+
function transpose(matrix) {
|
|
3794
|
+
return matrix[0] ? matrix[0].map((_, j) => matrix.map((row) => row[j])) : [];
|
|
3795
|
+
}
|
|
3796
|
+
function multiply(left, right) {
|
|
3797
|
+
return left.map((row) => right[0].map((_, j) => row.reduce((sum, value, m) => sum + value * right[m][j], 0)));
|
|
3798
|
+
}
|
|
3799
|
+
function diagonal(values) {
|
|
3800
|
+
return values.map((value, i) => values.map((_, j) => i === j ? value : 0));
|
|
3801
|
+
}
|
|
3802
|
+
function applyRow(vector, transform) {
|
|
3803
|
+
return transform[0].map((_, j) => vector.reduce((sum, value, m) => sum + value * transform[m][j], 0));
|
|
3804
|
+
}
|
|
3805
|
+
/** Inverse of a lower-triangular matrix by forward substitution. */
|
|
3806
|
+
function lowerInverse(lower) {
|
|
3807
|
+
const n = lower.length;
|
|
3808
|
+
const inverse = Array.from({ length: n }, () => new Array(n).fill(0));
|
|
3809
|
+
for (let j = 0; j < n; j++) {
|
|
3810
|
+
inverse[j][j] = 1 / lower[j][j];
|
|
3811
|
+
for (let i = j + 1; i < n; i++) {
|
|
3812
|
+
let sum = 0;
|
|
3813
|
+
for (let m = j; m < i; m++) sum += lower[i][m] * inverse[m][j];
|
|
3814
|
+
inverse[i][j] = -sum / lower[i][i];
|
|
3815
|
+
}
|
|
3816
|
+
}
|
|
3817
|
+
return inverse;
|
|
3818
|
+
}
|
|
3819
|
+
/** The manifold as text for a terminal or a proposer's context. */
|
|
3820
|
+
function formatSkillManifold(lens) {
|
|
3821
|
+
const { data, signal } = lens;
|
|
3822
|
+
const lines = [];
|
|
3823
|
+
const { matrix } = data;
|
|
3824
|
+
lines.push(`Skill manifold (${data.split} split, ${data.mode === "calibrated" ? `loadings from ${data.source}` : "loadings fitted here"})`);
|
|
3825
|
+
lines.push(` matrix: ${plural$1(matrix.rows, "node")} × ${plural$1(matrix.units, "unit")}, ${matrix.observed} observed cells (density ${matrix.density === null ? "unknown" : `${Math.round(matrix.density * 1e3) / 10}%`}); left out ${plural$1(matrix.excludedNodes, "node")} and ${plural$1(matrix.excludedUnits, "unit")}${matrix.fitRows > 0 && matrix.fitRows < matrix.rows ? `; factors fitted on ${matrix.fitRows} evenly spaced rows` : ""}`);
|
|
3826
|
+
const { intrinsicDimension: intrinsic } = data;
|
|
3827
|
+
if (intrinsic.value === null) lines.push(` intrinsic dimension: insufficient (${intrinsic.insufficient})`);
|
|
3828
|
+
else {
|
|
3829
|
+
const curve = intrinsic.curve.map((point) => `k=${point.rank} ${point.error.toFixed(3)}±${fixedOrDash(point.standardError)}`).join(", ");
|
|
3830
|
+
lines.push(` intrinsic dimension: ${intrinsic.value} (${intrinsic.folds}-fold held-out ${data.model?.link === "logit" ? "binomial log loss" : "squared error on the standardized scale"}, one-SE rule, ${intrinsic.n} held-out cells per rank: ${curve})`);
|
|
3831
|
+
}
|
|
3832
|
+
if (data.model === null) {
|
|
3833
|
+
if (data.insufficient !== intrinsic.insufficient) lines.push(` model: insufficient (${data.insufficient})`);
|
|
3834
|
+
return lines.join("\n");
|
|
3835
|
+
}
|
|
3836
|
+
const { model } = data;
|
|
3837
|
+
const explained = data.explained;
|
|
3838
|
+
const r2 = explained?.byRank.find((entry) => entry.rank === model.rank)?.r2;
|
|
3839
|
+
const logit = model.link === "logit";
|
|
3840
|
+
lines.push(` model: ${logit ? "item-response (two-parameter logistic, every cell 0 or 1)" : "linear on standardized means"}, rank ${model.rank}, ridge ${model.ridge}, ${model.iterations} ${logit ? "Newton" : "ALS"} sweeps${model.converged ? "" : " (not converged)"}${r2 === void 0 ? "" : `, in-sample ${logit ? "pseudo-R²" : "R²"} ${r2.toFixed(3)} over unit ${logit ? "easiness" : "means"}`}${explained && explained.axisShare.length > 0 ? `; axis shares ${explained.axisShare.map((share) => `${Math.round(share * 1e3) / 10}%`).join(", ")}` : ""}; ${logit ? "cell noise binomial" : `cell noise ${data.noiseVariance === null ? "unknown" : data.noiseVariance.toFixed(3)} (standardized)`}`);
|
|
3841
|
+
const strongest = [...data.units].sort((left, right) => right.strength - left.strength).slice(0, 5);
|
|
3842
|
+
lines.push(` units that separate nodes most: ${strongest.map((unit) => `${unit.unitId} (${unit.strength.toFixed(2)}, axis ${dominantAxis(unit.loading) + 1})`).join(", ")}`);
|
|
3843
|
+
if (data.leaders.length > 0) lines.push(` leaders by predicted split mean: ${data.leaders.map((leader) => `${leader.nodeId} ${leader.predictedMean.toFixed(3)} on ${leader.observed} units`).join("; ")}`);
|
|
3844
|
+
if (signal.value === null) lines.push(` next unit: insufficient (${signal.insufficient})`);
|
|
3845
|
+
else {
|
|
3846
|
+
const top = data.nextUnits.slice(0, 5).map((unit) => `${unit.unitId} ${(unit.share * 100).toFixed(1)}% (${unit.measuredBy}/${data.leaders.length} measured)`).join(", ");
|
|
3847
|
+
lines.push(` next unit: ${signal.subject}, removing an expected ${(signal.value * 100).toFixed(1)}% of the leaders' contrast variance per cell; ranked: ${top}`);
|
|
3848
|
+
}
|
|
3849
|
+
return lines.join("\n");
|
|
3850
|
+
}
|
|
3851
|
+
function dominantAxis(loading) {
|
|
3852
|
+
let best = 0;
|
|
3853
|
+
for (let index = 1; index < loading.length; index++) if (Math.abs(loading[index]) > Math.abs(loading[best])) best = index;
|
|
3854
|
+
return best;
|
|
3855
|
+
}
|
|
3856
|
+
function fixedOrDash(value) {
|
|
3857
|
+
return Number.isFinite(value) ? value.toFixed(3) : "–";
|
|
3858
|
+
}
|
|
3859
|
+
//#endregion
|
|
3860
|
+
//#region src/search/lenses/task-matrix.ts
|
|
3861
|
+
function taskMatrix(state, options = {}) {
|
|
3862
|
+
const split = options.split ?? rankingSplit(state);
|
|
3863
|
+
const nodeIds = state.nodeIds();
|
|
3864
|
+
const byNodeUnit = /* @__PURE__ */ new Map();
|
|
3865
|
+
const unitSet = /* @__PURE__ */ new Set();
|
|
3866
|
+
for (const nodeId of nodeIds) {
|
|
3867
|
+
const units = /* @__PURE__ */ new Map();
|
|
3868
|
+
for (const unit of state.unitScores(nodeId, split)) {
|
|
3869
|
+
units.set(unit.unitId, unit.mean);
|
|
3870
|
+
unitSet.add(unit.unitId);
|
|
3871
|
+
}
|
|
3872
|
+
byNodeUnit.set(nodeId, units);
|
|
3873
|
+
}
|
|
3874
|
+
const unitIds = [...unitSet].sort((a, b) => a.localeCompare(b));
|
|
3875
|
+
const nodeClusters = clusterBy(nodeIds, (a, b) => nodeDistance(byNodeUnit, a, b));
|
|
3876
|
+
const unitClusters = clusterBy(unitIds, (a, b) => unitDistance(byNodeUnit, nodeIds, a, b));
|
|
3877
|
+
const orderedNodeIds = nodeClusters.flatMap((c) => c.members);
|
|
3878
|
+
const orderedUnitIds = unitClusters.flatMap((c) => c.members);
|
|
3879
|
+
const cells = [];
|
|
3880
|
+
for (const nodeId of orderedNodeIds) {
|
|
3881
|
+
const units = byNodeUnit.get(nodeId);
|
|
3882
|
+
for (const unitId of orderedUnitIds) cells.push({
|
|
3883
|
+
nodeId,
|
|
3884
|
+
unitId,
|
|
3885
|
+
mean: units.get(unitId) ?? null
|
|
3886
|
+
});
|
|
3887
|
+
}
|
|
3888
|
+
const specialistGain = unitClusters.map((cluster) => {
|
|
3889
|
+
const nodeMeans = [];
|
|
3890
|
+
for (const nodeId of nodeIds) {
|
|
3891
|
+
const units = byNodeUnit.get(nodeId);
|
|
3892
|
+
const scored = cluster.members.map((unitId) => units.get(unitId)).filter((v) => v !== void 0);
|
|
3893
|
+
if (scored.length > 0) nodeMeans.push(scored.reduce((a, b) => a + b, 0) / scored.length);
|
|
3894
|
+
}
|
|
3895
|
+
if (nodeMeans.length < 2) return {
|
|
3896
|
+
clusterId: cluster.id,
|
|
3897
|
+
unitIds: cluster.members,
|
|
3898
|
+
gain: null,
|
|
3899
|
+
nodesContributing: nodeMeans.length
|
|
3900
|
+
};
|
|
3901
|
+
const mean = nodeMeans.reduce((a, b) => a + b, 0) / nodeMeans.length;
|
|
3902
|
+
const gain = Math.max(...nodeMeans) - mean;
|
|
3903
|
+
return {
|
|
3904
|
+
clusterId: cluster.id,
|
|
3905
|
+
unitIds: cluster.members,
|
|
3906
|
+
gain,
|
|
3907
|
+
nodesContributing: nodeMeans.length
|
|
3908
|
+
};
|
|
3909
|
+
});
|
|
3910
|
+
const signalValue = {};
|
|
3911
|
+
for (const row of specialistGain) if (row.gain !== null) signalValue[row.clusterId] = row.gain;
|
|
3912
|
+
return {
|
|
3913
|
+
data: {
|
|
3914
|
+
split,
|
|
3915
|
+
nodeIds: orderedNodeIds,
|
|
3916
|
+
unitIds: orderedUnitIds,
|
|
3917
|
+
nodeClusters,
|
|
3918
|
+
unitClusters,
|
|
3919
|
+
cells,
|
|
3920
|
+
specialistGain
|
|
3921
|
+
},
|
|
3922
|
+
signal: {
|
|
3923
|
+
name: "taskMatrix.specialistGain",
|
|
3924
|
+
value: signalValue
|
|
3925
|
+
}
|
|
3926
|
+
};
|
|
3927
|
+
}
|
|
3928
|
+
function nodeDistance(byNodeUnit, a, b) {
|
|
3929
|
+
const unitsA = byNodeUnit.get(a);
|
|
3930
|
+
const unitsB = byNodeUnit.get(b);
|
|
3931
|
+
let sumSquares = 0;
|
|
3932
|
+
let shared = 0;
|
|
3933
|
+
for (const [unitId, valueA] of unitsA) {
|
|
3934
|
+
const valueB = unitsB.get(unitId);
|
|
3935
|
+
if (valueB === void 0) continue;
|
|
3936
|
+
sumSquares += (valueA - valueB) ** 2;
|
|
3937
|
+
shared += 1;
|
|
3938
|
+
}
|
|
3939
|
+
return shared === 0 ? Number.POSITIVE_INFINITY : Math.sqrt(sumSquares);
|
|
3940
|
+
}
|
|
3941
|
+
function unitDistance(byNodeUnit, nodeIds, a, b) {
|
|
3942
|
+
let sumSquares = 0;
|
|
3943
|
+
let shared = 0;
|
|
3944
|
+
for (const nodeId of nodeIds) {
|
|
3945
|
+
const units = byNodeUnit.get(nodeId);
|
|
3946
|
+
const valueA = units.get(a);
|
|
3947
|
+
const valueB = units.get(b);
|
|
3948
|
+
if (valueA === void 0 || valueB === void 0) continue;
|
|
3949
|
+
sumSquares += (valueA - valueB) ** 2;
|
|
3950
|
+
shared += 1;
|
|
3951
|
+
}
|
|
3952
|
+
return shared === 0 ? Number.POSITIVE_INFINITY : Math.sqrt(sumSquares);
|
|
3953
|
+
}
|
|
3954
|
+
/**
|
|
3955
|
+
* Single-linkage agglomerative clustering. The cutoff is the median of the
|
|
3956
|
+
* finite pairwise distances measured before any merge — a threshold the data
|
|
3957
|
+
* itself sets, not a constant this lens chooses. Two items with no finite
|
|
3958
|
+
* distance (no shared observation) can never merge. Deterministic: ties in
|
|
3959
|
+
* the merge order break on the lexicographically smaller pair, and the
|
|
3960
|
+
* returned clusters and their members are both sorted.
|
|
3961
|
+
*/
|
|
3962
|
+
function clusterBy(ids, distance) {
|
|
3963
|
+
let clusters = ids.map((id) => [id]);
|
|
3964
|
+
if (clusters.length <= 1) return clusters.map(toCluster);
|
|
3965
|
+
const finite = [];
|
|
3966
|
+
for (let i = 0; i < ids.length; i++) for (let j = i + 1; j < ids.length; j++) {
|
|
3967
|
+
const d = distance(ids[i], ids[j]);
|
|
3968
|
+
if (Number.isFinite(d)) finite.push(d);
|
|
3969
|
+
}
|
|
3970
|
+
if (finite.length === 0) return clusters.map(toCluster);
|
|
3971
|
+
finite.sort((a, b) => a - b);
|
|
3972
|
+
const cutoff = finite[Math.floor(finite.length / 2)];
|
|
3973
|
+
for (;;) {
|
|
3974
|
+
let best = null;
|
|
3975
|
+
for (let i = 0; i < clusters.length; i++) for (let j = i + 1; j < clusters.length; j++) {
|
|
3976
|
+
let d = Number.POSITIVE_INFINITY;
|
|
3977
|
+
for (const a of clusters[i]) for (const b of clusters[j]) d = Math.min(d, distance(a, b));
|
|
3978
|
+
if (d > cutoff) continue;
|
|
3979
|
+
if (best === null || d < best.d) best = {
|
|
3980
|
+
i,
|
|
3981
|
+
j,
|
|
3982
|
+
d
|
|
3983
|
+
};
|
|
3984
|
+
}
|
|
3985
|
+
if (!best) break;
|
|
3986
|
+
const merged = [...clusters[best.i], ...clusters[best.j]];
|
|
3987
|
+
clusters = clusters.filter((_, index) => index !== best.i && index !== best.j);
|
|
3988
|
+
clusters.push(merged);
|
|
3989
|
+
}
|
|
3990
|
+
return clusters.map(toCluster).sort((a, b) => a.id.localeCompare(b.id));
|
|
3991
|
+
}
|
|
3992
|
+
function toCluster(members) {
|
|
3993
|
+
const sorted = [...members].sort((a, b) => a.localeCompare(b));
|
|
3994
|
+
return {
|
|
3995
|
+
id: sorted[0],
|
|
3996
|
+
members: sorted
|
|
3997
|
+
};
|
|
3998
|
+
}
|
|
3999
|
+
//#endregion
|
|
4000
|
+
//#region src/search/lenses/tree.ts
|
|
4001
|
+
function tree(state) {
|
|
4002
|
+
const nodes = state.nodes();
|
|
4003
|
+
const byId = new Map(nodes.map((node) => [node.nodeId, node]));
|
|
4004
|
+
const childrenOf = /* @__PURE__ */ new Map();
|
|
4005
|
+
const topLevel = [];
|
|
4006
|
+
for (const node of nodes) if (node.primaryParentId !== null && byId.has(node.primaryParentId)) {
|
|
4007
|
+
const list = childrenOf.get(node.primaryParentId);
|
|
4008
|
+
if (list) list.push(node.nodeId);
|
|
4009
|
+
else childrenOf.set(node.primaryParentId, [node.nodeId]);
|
|
4010
|
+
} else topLevel.push(node.nodeId);
|
|
4011
|
+
const primaryOperator = /* @__PURE__ */ new Map();
|
|
4012
|
+
for (const edge of state.edges()) {
|
|
4013
|
+
const child = byId.get(edge.childNodeId);
|
|
4014
|
+
if (!child) continue;
|
|
4015
|
+
if ((edge.parents[0]?.nodeId ?? null) === child.primaryParentId && !primaryOperator.has(child.nodeId)) primaryOperator.set(child.nodeId, edge.operator);
|
|
4016
|
+
}
|
|
4017
|
+
const build = (nodeId) => {
|
|
4018
|
+
const node = byId.get(nodeId);
|
|
4019
|
+
return {
|
|
4020
|
+
nodeId: node.nodeId,
|
|
4021
|
+
status: node.status,
|
|
4022
|
+
rung: node.rung,
|
|
4023
|
+
depth: node.depth,
|
|
4024
|
+
cellCount: node.cellCount,
|
|
4025
|
+
knownCostUsd: node.spend.knownUsd,
|
|
4026
|
+
operator: primaryOperator.get(node.nodeId) ?? null,
|
|
4027
|
+
children: (childrenOf.get(nodeId) ?? []).map((childId) => build(childId))
|
|
4028
|
+
};
|
|
4029
|
+
};
|
|
4030
|
+
return {
|
|
4031
|
+
data: {
|
|
4032
|
+
roots: topLevel.map(build),
|
|
4033
|
+
nodeCount: nodes.length,
|
|
4034
|
+
edgeCount: state.edges().length
|
|
4035
|
+
},
|
|
4036
|
+
signal: {
|
|
4037
|
+
name: "tree.nodeCount",
|
|
4038
|
+
value: nodes.length
|
|
4039
|
+
}
|
|
4040
|
+
};
|
|
4041
|
+
}
|
|
4042
|
+
//#endregion
|
|
4043
|
+
export { editCreditText as A, draftOnPlateau as C, EDIT_CREDIT_ESTIMATOR as D, front as E, EDIT_CREDIT_SIGNAL as O, vectorEmbedding as S, screenedNodes as T, lineageEdits as _, skillCalibration as a, surfaceDigestEdits as b, META_SEARCH_SIGNAL as c, objectiveKey as d, renderMetaSearchText as f, lineEditDistance as g, landscape as h, nextUnitExtension as i, editCredit as k, metaSearch as l, formatLandscape as m, taskMatrix as n, skillManifold as o, searchPolicyGenome as p, formatSkillManifold as r, META_SEARCH_SCORE_SOURCE as s, tree as t, metaSearchScore as u, nearestNeighbours as v, searchPlateau as w, surfaceTextEdits as x, profileTextLines as y };
|
|
4044
|
+
|
|
4045
|
+
//# sourceMappingURL=tree-DHHWsH-X.js.map
|