@inetafrica/open-claudia 2.6.57 β 2.6.59
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 +8 -0
- package/README.md +8 -1
- package/bin/cli.js +4 -0
- package/bin/kpi.js +55 -0
- package/core/dream.js +384 -11
- package/core/pack-review.js +45 -4
- package/core/packs.js +37 -5
- package/core/recall/discoverer.js +219 -50
- package/core/recall/graph.js +29 -2
- package/core/recall/kpi.js +227 -0
- package/core/recall/metrics.js +216 -9
- package/core/recall/tuning.js +125 -0
- package/core/recall/warm-walker.js +12 -1
- package/core/runner.js +5 -1
- package/core/system-prompt.js +5 -1
- package/core/transcript-index.js +21 -1
- package/package.json +2 -2
- package/test-recall-discoverer.js +52 -8
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// Per-version KPI aggregation over recall telemetry. Ground truth is the
|
|
2
|
+
// per-turn JSONL (every record version+model stamped); the nightly dream
|
|
3
|
+
// snapshots in recall-kpi.jsonl are the time series. Deterministic and
|
|
4
|
+
// read-only β powers `open-claudia kpi` (text table, JSON, and a
|
|
5
|
+
// self-contained HTML report with inline SVG charts, no CDN).
|
|
6
|
+
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const metrics = require("./metrics");
|
|
9
|
+
|
|
10
|
+
const LEGACY_VERSION = "pre-2.6.59"; // records logged before version stamping shipped
|
|
11
|
+
|
|
12
|
+
function emptyBucket(v) {
|
|
13
|
+
return {
|
|
14
|
+
v, models: new Set(), firstTs: "", lastTs: "",
|
|
15
|
+
turns: 0, gated: 0, seedsTier: 0, keptTotal: 0,
|
|
16
|
+
rescueTurns: 0, rescues: 0, walkerFailed: 0,
|
|
17
|
+
latencyMsTotal: 0, costUsd: 0, autoKept: 0, autoDropped: 0,
|
|
18
|
+
opens: 0, reviewerUses: 0, byTier: {},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Stream the per-turn log into per-version buckets. Use-records (π opens /
|
|
23
|
+
// reviewer adjudications) are attributed to the version that observed the use.
|
|
24
|
+
function scanLog() {
|
|
25
|
+
let lines = [];
|
|
26
|
+
try { lines = fs.readFileSync(metrics.LOG_FILE, "utf8").split("\n").filter(Boolean); }
|
|
27
|
+
catch (e) { return new Map(); }
|
|
28
|
+
const byV = new Map();
|
|
29
|
+
const bucket = (v) => { if (!byV.has(v)) byV.set(v, emptyBucket(v)); return byV.get(v); };
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
let rec; try { rec = JSON.parse(line); } catch (e) { continue; }
|
|
32
|
+
const b = bucket(rec.v || LEGACY_VERSION);
|
|
33
|
+
if (!b.firstTs) b.firstTs = rec.ts || "";
|
|
34
|
+
if (rec.ts) b.lastTs = rec.ts;
|
|
35
|
+
if (Array.isArray(rec.use)) {
|
|
36
|
+
if (rec.source === "reviewer") b.reviewerUses += rec.use.length;
|
|
37
|
+
else b.opens += rec.use.length;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
b.turns++;
|
|
41
|
+
if (rec.model) b.models.add(rec.model);
|
|
42
|
+
b.latencyMsTotal += rec.latencyMs || 0;
|
|
43
|
+
b.costUsd += rec.costUsd || 0;
|
|
44
|
+
if (rec.tier) {
|
|
45
|
+
const t = b.byTier[rec.tier] || { n: 0, ms: 0 };
|
|
46
|
+
t.n++; t.ms += rec.latencyMs || 0;
|
|
47
|
+
b.byTier[rec.tier] = t;
|
|
48
|
+
}
|
|
49
|
+
if (rec.gated) { b.gated++; continue; }
|
|
50
|
+
if (rec.tier === "seeds") b.seedsTier++;
|
|
51
|
+
if (rec.walker === "failed") b.walkerFailed++;
|
|
52
|
+
const kept = rec.kept || [];
|
|
53
|
+
b.keptTotal += kept.length;
|
|
54
|
+
const seedIds = new Set((rec.seeds || []).map((s) => s.id));
|
|
55
|
+
const rescues = kept.filter((k) => !seedIds.has(k.id)).length;
|
|
56
|
+
if (rescues > 0) { b.rescueTurns++; b.rescues += rescues; }
|
|
57
|
+
if (rec.auto) {
|
|
58
|
+
b.autoKept += (rec.auto.kept || []).length;
|
|
59
|
+
b.autoDropped += (rec.auto.dropped || []).length;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return byV;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const r1 = (n) => Math.round(n * 10) / 10;
|
|
66
|
+
const pct = (n, d) => (d ? Math.round((n / d) * 100) : null);
|
|
67
|
+
|
|
68
|
+
// One comparable row per version, ordered oldestβnewest by first sighting.
|
|
69
|
+
function aggregate() {
|
|
70
|
+
const rows = [];
|
|
71
|
+
for (const b of scanLog().values()) {
|
|
72
|
+
const used = b.opens + b.reviewerUses;
|
|
73
|
+
rows.push({
|
|
74
|
+
v: b.v,
|
|
75
|
+
models: [...b.models].sort(),
|
|
76
|
+
from: (b.firstTs || "").slice(0, 10),
|
|
77
|
+
to: (b.lastTs || "").slice(0, 10),
|
|
78
|
+
turns: b.turns,
|
|
79
|
+
gatedPct: pct(b.gated, b.turns),
|
|
80
|
+
seedsTierPct: pct(b.seedsTier, b.turns),
|
|
81
|
+
keptTotal: b.keptTotal,
|
|
82
|
+
avgKept: b.turns - b.gated ? r1(b.keptTotal / (b.turns - b.gated)) : 0,
|
|
83
|
+
rescuePct: pct(b.rescueTurns, b.turns - b.gated),
|
|
84
|
+
noisePct: b.keptTotal ? Math.max(0, 100 - Math.round((used / b.keptTotal) * 100)) : null,
|
|
85
|
+
opens: b.opens,
|
|
86
|
+
reviewerUses: b.reviewerUses,
|
|
87
|
+
avgLatencyMs: b.turns ? Math.round(b.latencyMsTotal / b.turns) : 0,
|
|
88
|
+
byTier: Object.fromEntries(Object.entries(b.byTier).map(([t, x]) => [t, { turns: x.n, avgMs: x.n ? Math.round(x.ms / x.n) : 0 }])),
|
|
89
|
+
costUsd: r1(b.costUsd * 10) / 10,
|
|
90
|
+
costPerTurnUsd: b.turns ? Math.round((b.costUsd / b.turns) * 10000) / 10000 : 0,
|
|
91
|
+
walkerFailPct: pct(b.walkerFailed, b.turns - b.gated),
|
|
92
|
+
autoKept: b.autoKept,
|
|
93
|
+
autoDropped: b.autoDropped,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
rows.sort((a, b) => (a.from || "").localeCompare(b.from || ""));
|
|
97
|
+
return rows;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function trend() { return metrics.readKpi(); }
|
|
101
|
+
|
|
102
|
+
// ββ text table (CLI) ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
103
|
+
function renderTable(rows) {
|
|
104
|
+
if (!rows.length) return "No recall telemetry yet.";
|
|
105
|
+
const cols = [
|
|
106
|
+
["version", (r) => r.v],
|
|
107
|
+
["turns", (r) => String(r.turns)],
|
|
108
|
+
["rescue", (r) => (r.rescuePct == null ? "β" : r.rescuePct + "%")],
|
|
109
|
+
["noise", (r) => (r.noisePct == null ? "β" : r.noisePct + "%")],
|
|
110
|
+
["avg", (r) => (r.avgLatencyMs / 1000).toFixed(1) + "s"],
|
|
111
|
+
["gated", (r) => (r.gatedPct == null ? "β" : r.gatedPct + "%")],
|
|
112
|
+
["$ / turn", (r) => "$" + r.costPerTurnUsd.toFixed(3)],
|
|
113
|
+
["cascade", (r) => (r.autoKept || r.autoDropped ? `${r.autoKept}/${r.autoDropped}` : "β")],
|
|
114
|
+
["walker-fail", (r) => (r.walkerFailPct == null ? "β" : r.walkerFailPct + "%")],
|
|
115
|
+
["window", (r) => `${r.from}β${r.to}`],
|
|
116
|
+
["models", (r) => r.models.join(",") || "β"],
|
|
117
|
+
];
|
|
118
|
+
const widths = cols.map(([h, f]) => Math.max(h.length, ...rows.map((r) => f(r).length)));
|
|
119
|
+
const line = (cells) => cells.map((c, i) => c.padEnd(widths[i])).join(" ");
|
|
120
|
+
const out = [line(cols.map(([h]) => h)), line(widths.map((w) => "-".repeat(w)))];
|
|
121
|
+
for (const r of rows) out.push(line(cols.map(([, f]) => f(r))));
|
|
122
|
+
return out.join("\n");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ββ HTML report (inline SVG, no dependencies) βββββββββββββββββββββββββ
|
|
126
|
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
127
|
+
const PALETTE = ["#4f8ef7", "#f7734f", "#3dbf7a", "#b06ef7", "#f0b429", "#e8618c", "#38bdc8"];
|
|
128
|
+
|
|
129
|
+
function barChart(title, items, { fmt = (v) => String(v), max = null } = {}) {
|
|
130
|
+
const data = items.filter((i) => i.value != null && Number.isFinite(i.value));
|
|
131
|
+
if (!data.length) return "";
|
|
132
|
+
const W = 560, ROW = 30, LABEL = 150, PAD = 8;
|
|
133
|
+
const H = data.length * ROW + 34;
|
|
134
|
+
const top = max != null ? max : Math.max(...data.map((d) => d.value), 1e-9);
|
|
135
|
+
const bars = data.map((d, i) => {
|
|
136
|
+
const w = Math.max(2, Math.round((d.value / top) * (W - LABEL - 90)));
|
|
137
|
+
const y = 30 + i * ROW;
|
|
138
|
+
return `<text x="${LABEL - PAD}" y="${y + 15}" text-anchor="end" class="lb">${esc(d.label)}</text>` +
|
|
139
|
+
`<rect x="${LABEL}" y="${y + 3}" width="${w}" height="${ROW - 10}" rx="4" fill="${PALETTE[i % PALETTE.length]}"/>` +
|
|
140
|
+
`<text x="${LABEL + w + 6}" y="${y + 15}" class="vl">${esc(fmt(d.value))}</text>`;
|
|
141
|
+
}).join("");
|
|
142
|
+
return `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg">` +
|
|
143
|
+
`<text x="0" y="16" class="tt">${esc(title)}</text>${bars}</svg>`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function lineChart(title, series, { fmt = (v) => String(v) } = {}) {
|
|
147
|
+
const all = series.flatMap((s) => s.points.map((p) => p.y)).filter((y) => Number.isFinite(y));
|
|
148
|
+
const n = Math.max(...series.map((s) => s.points.length), 0);
|
|
149
|
+
if (!all.length || n < 2) return "";
|
|
150
|
+
const W = 560, H = 190, L = 46, B = 24, T = 26;
|
|
151
|
+
const top = Math.max(...all, 1e-9);
|
|
152
|
+
const x = (i) => L + (i / (n - 1)) * (W - L - 12);
|
|
153
|
+
const y = (v) => T + (1 - v / top) * (H - T - B);
|
|
154
|
+
const paths = series.map((s, si) => {
|
|
155
|
+
const pts = s.points.map((p, i) => `${x(i).toFixed(1)},${y(p.y).toFixed(1)}`).join(" ");
|
|
156
|
+
return `<polyline points="${pts}" fill="none" stroke="${PALETTE[si % PALETTE.length]}" stroke-width="2"/>`;
|
|
157
|
+
}).join("");
|
|
158
|
+
const legend = series.map((s, si) =>
|
|
159
|
+
`<circle cx="${L + si * 130}" cy="${H - 8}" r="4" fill="${PALETTE[si % PALETTE.length]}"/>` +
|
|
160
|
+
`<text x="${L + si * 130 + 9}" y="${H - 4}" class="lb">${esc(s.label)}</text>`).join("");
|
|
161
|
+
const grid = [0, 0.5, 1].map((f) =>
|
|
162
|
+
`<line x1="${L}" y1="${y(top * f)}" x2="${W - 12}" y2="${y(top * f)}" class="gr"/>` +
|
|
163
|
+
`<text x="${L - 5}" y="${y(top * f) + 4}" text-anchor="end" class="vl">${esc(fmt(top * f))}</text>`).join("");
|
|
164
|
+
return `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg">` +
|
|
165
|
+
`<text x="0" y="16" class="tt">${esc(title)}</text>${grid}${paths}${legend}</svg>`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function htmlReport() {
|
|
169
|
+
const rows = aggregate();
|
|
170
|
+
const snaps = trend().filter((s) => s.health && s.window && s.window.turns > 0);
|
|
171
|
+
const label = (r) => `${r.v} (${r.turns}t)`;
|
|
172
|
+
const charts = [
|
|
173
|
+
barChart("Graph rescue rate β % of judged turns where memory was saved by the graph, not keywords (higher = better transfer)",
|
|
174
|
+
rows.map((r) => ({ label: label(r), value: r.rescuePct })), { fmt: (v) => Math.round(v) + "%", max: 100 }),
|
|
175
|
+
barChart("Noise β % of injected memories never opened or credited by the reviewer (lower is better)",
|
|
176
|
+
rows.map((r) => ({ label: label(r), value: r.noisePct })), { fmt: (v) => Math.round(v) + "%", max: 100 }),
|
|
177
|
+
barChart("Average recall latency", rows.map((r) => ({ label: label(r), value: r.avgLatencyMs / 1000 })), { fmt: (v) => v.toFixed(1) + "s" }),
|
|
178
|
+
barChart("Recall cost per turn", rows.map((r) => ({ label: label(r), value: r.costPerTurnUsd })), { fmt: (v) => "$" + v.toFixed(3) }),
|
|
179
|
+
barChart("Gated turns β pleasantries skipped for free", rows.map((r) => ({ label: label(r), value: r.gatedPct })), { fmt: (v) => Math.round(v) + "%", max: 100 }),
|
|
180
|
+
barChart("Walker failure rate", rows.map((r) => ({ label: label(r), value: r.walkerFailPct })), { fmt: (v) => Math.round(v) + "%", max: 100 }),
|
|
181
|
+
].filter(Boolean).map((s) => `<div class="card">${s}</div>`).join("\n");
|
|
182
|
+
|
|
183
|
+
const trendChart = snaps.length >= 2 ? lineChart(
|
|
184
|
+
"Nightly trend (per dream window)",
|
|
185
|
+
[
|
|
186
|
+
{ label: "rescue %", points: snaps.map((s) => ({ y: (s.health.rescueRate || 0) * 100 })) },
|
|
187
|
+
{ label: "noise %", points: snaps.map((s) => ({ y: (s.health.noiseRate || 0) * 100 })) },
|
|
188
|
+
{ label: "latency s", points: snaps.map((s) => ({ y: (s.health.avgLatencyMs || 0) / 1000 })) },
|
|
189
|
+
],
|
|
190
|
+
{ fmt: (v) => Math.round(v) }) : "";
|
|
191
|
+
|
|
192
|
+
const tierRows = rows.map((r) => {
|
|
193
|
+
const t = Object.entries(r.byTier).map(([k, v]) => `${k}: ${v.turns}Γ @ ${(v.avgMs / 1000).toFixed(1)}s`).join(" Β· ") || "β";
|
|
194
|
+
return `<tr><td>${esc(r.v)}</td><td>${esc(r.from)}β${esc(r.to)}</td><td>${r.turns}</td><td>${t}</td>` +
|
|
195
|
+
`<td>${r.opens} / ${r.reviewerUses}</td><td>${r.autoKept} / ${r.autoDropped}</td><td>${esc(r.models.join(", ") || "β")}</td></tr>`;
|
|
196
|
+
}).join("\n");
|
|
197
|
+
|
|
198
|
+
return `<!doctype html>
|
|
199
|
+
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
200
|
+
<title>Open Claudia β recall KPIs</title>
|
|
201
|
+
<style>
|
|
202
|
+
body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;background:#101418;color:#e6edf3;margin:0;padding:24px;max-width:660px;margin:auto}
|
|
203
|
+
h1{font-size:20px;margin:0 0 4px} .sub{color:#8b98a5;font-size:13px;margin-bottom:20px}
|
|
204
|
+
.card{background:#161c23;border:1px solid #232c36;border-radius:10px;padding:14px;margin-bottom:14px}
|
|
205
|
+
svg{width:100%;height:auto} .tt{fill:#e6edf3;font-size:12px;font-weight:600} .lb{fill:#aeb8c2;font-size:11px}
|
|
206
|
+
.vl{fill:#8b98a5;font-size:10px} .gr{stroke:#232c36;stroke-width:1}
|
|
207
|
+
table{width:100%;border-collapse:collapse;font-size:12px} td,th{padding:6px 8px;border-bottom:1px solid #232c36;text-align:left}
|
|
208
|
+
th{color:#8b98a5;font-weight:600}
|
|
209
|
+
</style></head><body>
|
|
210
|
+
<h1>π§ Open Claudia β recall KPIs by version</h1>
|
|
211
|
+
<div class="sub">Generated ${new Date().toISOString().slice(0, 16).replace("T", " ")} Β· ${rows.reduce((a, r) => a + r.turns, 0)} recall turns Β· ${snaps.length} dream snapshot${snaps.length === 1 ? "" : "s"}</div>
|
|
212
|
+
${charts}
|
|
213
|
+
${trendChart ? `<div class="card">${trendChart}</div>` : ""}
|
|
214
|
+
<div class="card"><table>
|
|
215
|
+
<tr><th>version</th><th>window</th><th>turns</th><th>latency by tier</th><th>opens / reviewer</th><th>cascade kept / dropped</th><th>walker model</th></tr>
|
|
216
|
+
${tierRows}
|
|
217
|
+
</table></div>
|
|
218
|
+
</body></html>`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function writeHtmlReport(outPath) {
|
|
222
|
+
const p = outPath || require("path").join(require("../../config-dir"), "kpi-report.html");
|
|
223
|
+
fs.writeFileSync(p, htmlReport());
|
|
224
|
+
return p;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
module.exports = { LEGACY_VERSION, aggregate, trend, renderTable, htmlReport, writeHtmlReport };
|
package/core/recall/metrics.js
CHANGED
|
@@ -10,50 +10,104 @@ const CONFIG_DIR = require("../../config-dir");
|
|
|
10
10
|
|
|
11
11
|
const LOG_FILE = path.join(CONFIG_DIR, "recall-metrics.jsonl");
|
|
12
12
|
const SUMMARY_FILE = path.join(CONFIG_DIR, "recall-metrics-summary.json");
|
|
13
|
+
const NODE_STATS_FILE = path.join(CONFIG_DIR, "recall-node-stats.json");
|
|
14
|
+
const KPI_FILE = path.join(CONFIG_DIR, "recall-kpi.jsonl");
|
|
15
|
+
let VERSION = "";
|
|
16
|
+
try { VERSION = require("../../package.json").version || ""; } catch (e) {}
|
|
13
17
|
const MAX_OPEN_TRACK = 400; // recent turns kept to reconcile surfacedβopened
|
|
18
|
+
const MAX_NODES = 800; // node-stats map cap; lowest-activity entries pruned past this
|
|
19
|
+
const MAX_CO_RESCUE = 300;
|
|
14
20
|
|
|
15
21
|
function enabled() {
|
|
16
22
|
return String(process.env.RECALL_METRICS || "on").toLowerCase() !== "off";
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
// Record one recall turn. `entry`:
|
|
20
|
-
// { engine, query, seeds:[{id,score}], activated:[{id,activation,hop}],
|
|
21
|
-
// kept:[{id,why}],
|
|
26
|
+
// { engine, query, tier, seeds:[{id,score}], activated:[{id,activation,hop}],
|
|
27
|
+
// cand:[{id,act}], kept:[{id,why}], auto:{kept:[],dropped:[],shadow:{checks,agree}},
|
|
28
|
+
// gated (bool), latencyMs, seedMs, expandMs, walkerMs,
|
|
29
|
+
// walker ("warm"|"cold"|"off"|"failed"), costUsd }
|
|
22
30
|
function logTurn(entry) {
|
|
23
31
|
if (!enabled()) return;
|
|
24
32
|
try {
|
|
25
33
|
const rec = {
|
|
26
34
|
ts: new Date().toISOString(),
|
|
35
|
+
v: VERSION,
|
|
36
|
+
model: entry.model || "",
|
|
27
37
|
engine: entry.engine || "discoverer",
|
|
28
38
|
query: String(entry.query || "").slice(0, 200),
|
|
39
|
+
tier: entry.tier || "",
|
|
29
40
|
seeds: (entry.seeds || []).map((s) => ({ id: s.id, score: s.score })),
|
|
30
41
|
activated: (entry.activated || []).map((a) => ({ id: a.id, activation: round(a.activation), hop: a.hop })),
|
|
42
|
+
cand: (entry.cand || []).map((c) => ({ id: c.id, act: !!c.act })),
|
|
31
43
|
kept: (entry.kept || []).map((k) => ({ id: k.id, why: String(k.why || "").slice(0, 200) })),
|
|
44
|
+
auto: entry.auto || null,
|
|
32
45
|
gated: !!entry.gated,
|
|
33
46
|
latencyMs: entry.latencyMs || 0,
|
|
47
|
+
seedMs: entry.seedMs || 0,
|
|
48
|
+
expandMs: entry.expandMs || 0,
|
|
49
|
+
walkerMs: entry.walkerMs || 0,
|
|
50
|
+
walker: entry.walker || "",
|
|
34
51
|
costUsd: entry.costUsd || 0,
|
|
35
52
|
};
|
|
36
53
|
fs.appendFileSync(LOG_FILE, JSON.stringify(rec) + "\n");
|
|
37
54
|
bumpSummary(rec);
|
|
55
|
+
bumpNodeStats(rec);
|
|
38
56
|
} catch (e) {}
|
|
39
57
|
}
|
|
40
58
|
|
|
41
|
-
// Record which nodes were actually
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
59
|
+
// Record which nodes were actually used this turn. Matched against the most
|
|
60
|
+
// recent surfaced sets to derive precision/noise without a second pass over
|
|
61
|
+
// the whole log. `source` distinguishes how use was observed: "" / undefined
|
|
62
|
+
// for π opens (agent explicitly read the note), "reviewer" for post-turn
|
|
63
|
+
// reviewer adjudication (the turn's work updated that pack/entity).
|
|
64
|
+
function logUse(openedIds, source) {
|
|
45
65
|
if (!enabled()) return;
|
|
46
66
|
const ids = [...new Set((openedIds || []).filter(Boolean))];
|
|
47
67
|
if (!ids.length) return;
|
|
48
68
|
try {
|
|
49
|
-
|
|
69
|
+
const rec = { ts: new Date().toISOString(), v: VERSION, use: ids };
|
|
70
|
+
if (source) rec.source = String(source);
|
|
71
|
+
fs.appendFileSync(LOG_FILE, JSON.stringify(rec) + "\n");
|
|
72
|
+
const s = readSummary();
|
|
73
|
+
if (source === "reviewer") s.reviewerUses = (s.reviewerUses || 0) + ids.length;
|
|
74
|
+
else s.opens = (s.opens || 0) + ids.length;
|
|
75
|
+
writeSummary(s);
|
|
76
|
+
const ns = readNodeStats();
|
|
77
|
+
for (const id of ids) {
|
|
78
|
+
const n = nodeEntry(ns, id);
|
|
79
|
+
if (source === "reviewer") n.rev = (n.rev || 0) + 1;
|
|
80
|
+
else n.open = (n.open || 0) + 1;
|
|
81
|
+
n.last = today();
|
|
82
|
+
}
|
|
83
|
+
writeNodeStats(ns);
|
|
84
|
+
} catch (e) {}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// One walker-cascade shadow sample compared: the deterministic verdict vs the
|
|
88
|
+
// judge's. Divergence here is what tells the dream to distrust the cascade.
|
|
89
|
+
function logShadow(agree) {
|
|
90
|
+
if (!enabled()) return;
|
|
91
|
+
try {
|
|
92
|
+
const s = readSummary();
|
|
93
|
+
s.shadowChecks = (s.shadowChecks || 0) + 1;
|
|
94
|
+
if (agree) s.shadowAgree = (s.shadowAgree || 0) + 1;
|
|
95
|
+
writeSummary(s);
|
|
96
|
+
} catch (e) {}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Reviewer skip-gate fired: a turn deterministically judged not memory-worthy.
|
|
100
|
+
function logReviewerSkip() {
|
|
101
|
+
if (!enabled()) return;
|
|
102
|
+
try {
|
|
50
103
|
const s = readSummary();
|
|
51
|
-
s.
|
|
104
|
+
s.reviewerSkipped = (s.reviewerSkipped || 0) + 1;
|
|
52
105
|
writeSummary(s);
|
|
53
106
|
} catch (e) {}
|
|
54
107
|
}
|
|
55
108
|
|
|
56
109
|
function round(n) { return Math.round((Number(n) || 0) * 1000) / 1000; }
|
|
110
|
+
function today() { return new Date().toISOString().slice(0, 10); }
|
|
57
111
|
|
|
58
112
|
function readSummary() {
|
|
59
113
|
try { return JSON.parse(fs.readFileSync(SUMMARY_FILE, "utf8")) || {}; } catch (e) { return {}; }
|
|
@@ -62,10 +116,84 @@ function writeSummary(s) {
|
|
|
62
116
|
try { fs.writeFileSync(SUMMARY_FILE, JSON.stringify(s, null, 2)); } catch (e) {}
|
|
63
117
|
}
|
|
64
118
|
|
|
119
|
+
function readNodeStats() {
|
|
120
|
+
try { return JSON.parse(fs.readFileSync(NODE_STATS_FILE, "utf8")) || { nodes: {}, coRescue: {} }; }
|
|
121
|
+
catch (e) { return { nodes: {}, coRescue: {} }; }
|
|
122
|
+
}
|
|
123
|
+
function writeNodeStats(ns) {
|
|
124
|
+
try {
|
|
125
|
+
pruneNodeStats(ns);
|
|
126
|
+
ns.updatedAt = new Date().toISOString();
|
|
127
|
+
fs.writeFileSync(NODE_STATS_FILE, JSON.stringify(ns));
|
|
128
|
+
} catch (e) {}
|
|
129
|
+
}
|
|
130
|
+
function nodeEntry(ns, id) {
|
|
131
|
+
ns.nodes = ns.nodes || {};
|
|
132
|
+
if (!ns.nodes[id]) ns.nodes[id] = { seen: 0, act: 0, kept: 0, open: 0, rev: 0, resc: 0 };
|
|
133
|
+
return ns.nodes[id];
|
|
134
|
+
}
|
|
135
|
+
function pruneNodeStats(ns) {
|
|
136
|
+
const ids = Object.keys(ns.nodes || {});
|
|
137
|
+
if (ids.length > MAX_NODES) {
|
|
138
|
+
const scored = ids.map((id) => {
|
|
139
|
+
const n = ns.nodes[id];
|
|
140
|
+
return { id, w: (n.seen || 0) + (n.kept || 0) * 3 + (n.open || 0) * 3 + (n.rev || 0) * 3 };
|
|
141
|
+
}).sort((a, b) => a.w - b.w);
|
|
142
|
+
for (const { id } of scored.slice(0, ids.length - MAX_NODES)) delete ns.nodes[id];
|
|
143
|
+
}
|
|
144
|
+
const pairs = Object.entries(ns.coRescue || {});
|
|
145
|
+
if (pairs.length > MAX_CO_RESCUE) {
|
|
146
|
+
ns.coRescue = Object.fromEntries(pairs.sort((a, b) => b[1] - a[1]).slice(0, MAX_CO_RESCUE));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Per-node rolling counters β the evidence base for the walker cascade
|
|
151
|
+
// (keep-rates), negative Hebbian (activated-never-kept), and dream targeting.
|
|
152
|
+
// seen = surfaced as a candidate; act = of those, arrived via graph expansion;
|
|
153
|
+
// kept = walker/cascade kept it; open/rev = actually used; resc = kept without
|
|
154
|
+
// a seed match (graph rescue).
|
|
155
|
+
function bumpNodeStats(rec) {
|
|
156
|
+
if (rec.gated) return;
|
|
157
|
+
const ns = readNodeStats();
|
|
158
|
+
const keptIds = new Set(rec.kept.map((k) => k.id));
|
|
159
|
+
const seedIds = new Set(rec.seeds.map((x) => x.id));
|
|
160
|
+
const candList = rec.cand && rec.cand.length
|
|
161
|
+
? rec.cand
|
|
162
|
+
: [...rec.seeds.map((s) => ({ id: s.id, act: false })), ...rec.activated.map((a) => ({ id: a.id, act: true }))];
|
|
163
|
+
const seenIds = new Set();
|
|
164
|
+
for (const c of candList) {
|
|
165
|
+
if (seenIds.has(c.id) || c.id.startsWith("episode:")) continue;
|
|
166
|
+
seenIds.add(c.id);
|
|
167
|
+
const n = nodeEntry(ns, c.id);
|
|
168
|
+
n.seen++;
|
|
169
|
+
if (c.act) n.act++;
|
|
170
|
+
if (keptIds.has(c.id)) n.kept++;
|
|
171
|
+
n.last = today();
|
|
172
|
+
}
|
|
173
|
+
const rescued = [];
|
|
174
|
+
for (const k of rec.kept) {
|
|
175
|
+
if (k.id.startsWith("episode:")) continue;
|
|
176
|
+
if (!seenIds.has(k.id)) { const n = nodeEntry(ns, k.id); n.seen++; n.kept++; n.last = today(); }
|
|
177
|
+
if (!seedIds.has(k.id)) { nodeEntry(ns, k.id).resc++; rescued.push(k.id); }
|
|
178
|
+
}
|
|
179
|
+
// Co-rescue pairs: nodes repeatedly rescued together are missing a direct
|
|
180
|
+
// edge β the dream adds one deterministically past a threshold.
|
|
181
|
+
rescued.sort();
|
|
182
|
+
ns.coRescue = ns.coRescue || {};
|
|
183
|
+
for (let i = 0; i < rescued.length; i++) {
|
|
184
|
+
for (let j = i + 1; j < rescued.length; j++) {
|
|
185
|
+
const key = `${rescued[i]}|${rescued[j]}`;
|
|
186
|
+
ns.coRescue[key] = (ns.coRescue[key] || 0) + 1;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
writeNodeStats(ns);
|
|
190
|
+
}
|
|
191
|
+
|
|
65
192
|
function bumpSummary(rec) {
|
|
66
193
|
const s = readSummary();
|
|
67
194
|
s.turns = (s.turns || 0) + 1;
|
|
68
195
|
if (rec.gated) s.gatedTurns = (s.gatedTurns || 0) + 1;
|
|
196
|
+
if (rec.tier === "seeds") s.seedsOnlyTurns = (s.seedsOnlyTurns || 0) + 1;
|
|
69
197
|
s.seedsTotal = (s.seedsTotal || 0) + rec.seeds.length;
|
|
70
198
|
s.activatedTotal = (s.activatedTotal || 0) + rec.activated.length;
|
|
71
199
|
s.keptTotal = (s.keptTotal || 0) + rec.kept.length;
|
|
@@ -75,6 +203,16 @@ function bumpSummary(rec) {
|
|
|
75
203
|
if (rescues > 0) { s.rescueTurns = (s.rescueTurns || 0) + 1; s.rescues = (s.rescues || 0) + rescues; }
|
|
76
204
|
s.costUsd = round((s.costUsd || 0) + (rec.costUsd || 0));
|
|
77
205
|
s.latencyMsTotal = (s.latencyMsTotal || 0) + (rec.latencyMs || 0);
|
|
206
|
+
if (rec.tier) {
|
|
207
|
+
s.byTier = s.byTier || {};
|
|
208
|
+
const t = s.byTier[rec.tier] || { n: 0, ms: 0 };
|
|
209
|
+
t.n++; t.ms += rec.latencyMs || 0;
|
|
210
|
+
s.byTier[rec.tier] = t;
|
|
211
|
+
}
|
|
212
|
+
if (rec.auto) {
|
|
213
|
+
s.autoKept = (s.autoKept || 0) + ((rec.auto.kept || []).length);
|
|
214
|
+
s.autoDropped = (s.autoDropped || 0) + ((rec.auto.dropped || []).length);
|
|
215
|
+
}
|
|
78
216
|
s.updatedAt = new Date().toISOString();
|
|
79
217
|
writeSummary(s);
|
|
80
218
|
}
|
|
@@ -88,6 +226,8 @@ function summary() {
|
|
|
88
226
|
turns,
|
|
89
227
|
gatedTurns: s.gatedTurns || 0,
|
|
90
228
|
gatedPct: fmtPct(s.gatedTurns || 0, turns),
|
|
229
|
+
seedsOnlyTurns: s.seedsOnlyTurns || 0,
|
|
230
|
+
seedsOnlyPct: fmtPct(s.seedsOnlyTurns || 0, turns),
|
|
91
231
|
avgSeeds: turns ? round((s.seedsTotal || 0) / turns) : 0,
|
|
92
232
|
avgActivated: turns ? round((s.activatedTotal || 0) / turns) : 0,
|
|
93
233
|
avgKept: turns ? round((s.keptTotal || 0) / turns) : 0,
|
|
@@ -95,15 +235,82 @@ function summary() {
|
|
|
95
235
|
rescuePct: fmtPct(s.rescueTurns || 0, turns),
|
|
96
236
|
rescues: s.rescues || 0,
|
|
97
237
|
opens: s.opens || 0,
|
|
238
|
+
reviewerUses: s.reviewerUses || 0,
|
|
239
|
+
reviewerSkipped: s.reviewerSkipped || 0,
|
|
240
|
+
autoKept: s.autoKept || 0,
|
|
241
|
+
autoDropped: s.autoDropped || 0,
|
|
242
|
+
shadowChecks: s.shadowChecks || 0,
|
|
243
|
+
shadowAgreePct: fmtPct(s.shadowAgree || 0, s.shadowChecks || 0),
|
|
244
|
+
byTier: Object.fromEntries(Object.entries(s.byTier || {}).map(([t, v]) => [t, { turns: v.n, avgMs: v.n ? Math.round(v.ms / v.n) : 0 }])),
|
|
98
245
|
avgLatencyMs: turns ? Math.round((s.latencyMsTotal || 0) / turns) : 0,
|
|
99
246
|
costUsd: s.costUsd || 0,
|
|
100
247
|
updatedAt: s.updatedAt || "",
|
|
101
248
|
};
|
|
102
249
|
}
|
|
103
250
|
|
|
251
|
+
function nodeStats() { return readNodeStats(); }
|
|
252
|
+
|
|
253
|
+
// Dream-facing evidence pack: raw summary (for windowed deltas against a prior
|
|
254
|
+
// snapshot) plus targeted node lists the consolidation/tuning passes act on.
|
|
255
|
+
function evidence({ minSeen = 6, coRescueMin = 3 } = {}) {
|
|
256
|
+
const s = readSummary();
|
|
257
|
+
const ns = readNodeStats();
|
|
258
|
+
const nodes = ns.nodes || {};
|
|
259
|
+
const neverKept = [];
|
|
260
|
+
const highKeep = [];
|
|
261
|
+
const topRescued = [];
|
|
262
|
+
for (const [id, n] of Object.entries(nodes)) {
|
|
263
|
+
const used = (n.open || 0) + (n.rev || 0);
|
|
264
|
+
if ((n.seen || 0) >= minSeen && (n.kept || 0) === 0 && used === 0) {
|
|
265
|
+
neverKept.push({ id, seen: n.seen, act: n.act || 0 });
|
|
266
|
+
}
|
|
267
|
+
if ((n.seen || 0) >= 5 && (n.kept || 0) / n.seen >= 0.8 && used >= 1) {
|
|
268
|
+
highKeep.push({ id, seen: n.seen, kept: n.kept, used });
|
|
269
|
+
}
|
|
270
|
+
if ((n.resc || 0) >= 2) topRescued.push({ id, rescues: n.resc });
|
|
271
|
+
}
|
|
272
|
+
neverKept.sort((a, b) => b.seen - a.seen);
|
|
273
|
+
topRescued.sort((a, b) => b.rescues - a.rescues);
|
|
274
|
+
const coRescuePairs = Object.entries(ns.coRescue || {})
|
|
275
|
+
.filter(([, n]) => n >= coRescueMin)
|
|
276
|
+
.map(([key, n]) => { const [a, b] = key.split("|"); return { a, b, n }; })
|
|
277
|
+
.sort((x, y) => y.n - x.n);
|
|
278
|
+
return {
|
|
279
|
+
summary: s,
|
|
280
|
+
neverKept: neverKept.slice(0, 25),
|
|
281
|
+
highKeep: highKeep.slice(0, 25),
|
|
282
|
+
topRescued: topRescued.slice(0, 15),
|
|
283
|
+
coRescuePairs: coRescuePairs.slice(0, 15),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// One version-stamped KPI snapshot per dream night. This is the durable
|
|
288
|
+
// series `open-claudia kpi` aggregates to compare harness versions/models β
|
|
289
|
+
// per-turn ground truth stays in LOG_FILE, this is the cheap nightly rollup.
|
|
290
|
+
function appendKpi(snapshot) {
|
|
291
|
+
if (!enabled()) return;
|
|
292
|
+
try {
|
|
293
|
+
fs.appendFileSync(KPI_FILE, JSON.stringify({ ts: new Date().toISOString(), v: VERSION, ...snapshot }) + "\n");
|
|
294
|
+
} catch (e) {}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function readKpi() {
|
|
298
|
+
try {
|
|
299
|
+
return fs.readFileSync(KPI_FILE, "utf8").split("\n").filter(Boolean).map((l) => {
|
|
300
|
+
try { return JSON.parse(l); } catch (e) { return null; }
|
|
301
|
+
}).filter(Boolean);
|
|
302
|
+
} catch (e) { return []; }
|
|
303
|
+
}
|
|
304
|
+
|
|
104
305
|
function _resetForTest() {
|
|
105
306
|
try { fs.rmSync(LOG_FILE, { force: true }); } catch (e) {}
|
|
106
307
|
try { fs.rmSync(SUMMARY_FILE, { force: true }); } catch (e) {}
|
|
308
|
+
try { fs.rmSync(NODE_STATS_FILE, { force: true }); } catch (e) {}
|
|
309
|
+
try { fs.rmSync(KPI_FILE, { force: true }); } catch (e) {}
|
|
107
310
|
}
|
|
108
311
|
|
|
109
|
-
module.exports = {
|
|
312
|
+
module.exports = {
|
|
313
|
+
LOG_FILE, SUMMARY_FILE, NODE_STATS_FILE, KPI_FILE, VERSION, enabled,
|
|
314
|
+
logTurn, logUse, logShadow, logReviewerSkip, appendKpi, readKpi,
|
|
315
|
+
summary, nodeStats, evidence, _resetForTest,
|
|
316
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Dream-tunable recall knobs. The nightly dream is the ONLY writer; recall
|
|
2
|
+
// paths read lazily (mtime-cached) so a knob change applies without a bot
|
|
3
|
+
// restart. Every knob is hard-bounded here β a wild model proposal can never
|
|
4
|
+
// push a value outside its safe range. Precedence: env var (operator pin)
|
|
5
|
+
// > tuning file (dream) > default.
|
|
6
|
+
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
const CONFIG_DIR = require("../../config-dir");
|
|
10
|
+
|
|
11
|
+
const TUNING_FILE = path.join(CONFIG_DIR, "recall-tuning.json");
|
|
12
|
+
const MAX_HISTORY = 40;
|
|
13
|
+
|
|
14
|
+
// name -> { def, min, max, env } (numeric) or { def, values, env } (enum).
|
|
15
|
+
const KNOBS = {
|
|
16
|
+
walkerMaxCandidates: { def: 14, min: 8, max: 20, env: "RECALL_WALKER_MAX_CANDIDATES" },
|
|
17
|
+
episodeLimit: { def: 3, min: 1, max: 5, env: "RECALL_EPISODE_LIMIT" },
|
|
18
|
+
seedsTierMaxWords: { def: 4, min: 2, max: 6, env: "RECALL_SEEDS_TIER_MAX_WORDS" },
|
|
19
|
+
decayHalfLifeDays: { def: 60, min: 10, max: 90, env: "RECALL_DECAY_HALF_LIFE_DAYS" },
|
|
20
|
+
cascade: { def: "on", values: ["on", "off"], env: "RECALL_CASCADE" },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
let _cache = null;
|
|
24
|
+
let _cacheMtime = -1;
|
|
25
|
+
|
|
26
|
+
function readFile() {
|
|
27
|
+
let mtime = 0;
|
|
28
|
+
try { mtime = fs.statSync(TUNING_FILE).mtimeMs; } catch (e) { mtime = 0; }
|
|
29
|
+
if (_cache && mtime === _cacheMtime) return _cache;
|
|
30
|
+
let data = {};
|
|
31
|
+
try { data = JSON.parse(fs.readFileSync(TUNING_FILE, "utf8")) || {}; } catch (e) { data = {}; }
|
|
32
|
+
_cache = data;
|
|
33
|
+
_cacheMtime = mtime;
|
|
34
|
+
return data;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function clampKnob(name, value) {
|
|
38
|
+
const spec = KNOBS[name];
|
|
39
|
+
if (!spec) return null;
|
|
40
|
+
if (spec.values) {
|
|
41
|
+
const v = String(value).toLowerCase();
|
|
42
|
+
return spec.values.includes(v) ? v : spec.def;
|
|
43
|
+
}
|
|
44
|
+
const n = Number(value);
|
|
45
|
+
if (!Number.isFinite(n)) return spec.def;
|
|
46
|
+
return Math.min(spec.max, Math.max(spec.min, n));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Effective value of a knob, honouring env pin > tuning file > default.
|
|
50
|
+
function get(name) {
|
|
51
|
+
const spec = KNOBS[name];
|
|
52
|
+
if (!spec) return undefined;
|
|
53
|
+
if (spec.env && process.env[spec.env] !== undefined && process.env[spec.env] !== "") {
|
|
54
|
+
return clampKnob(name, process.env[spec.env]);
|
|
55
|
+
}
|
|
56
|
+
const data = readFile();
|
|
57
|
+
if (data.knobs && data.knobs[name] !== undefined) return clampKnob(name, data.knobs[name]);
|
|
58
|
+
return spec.def;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function knobTable() {
|
|
62
|
+
return Object.entries(KNOBS).map(([name, spec]) => ({
|
|
63
|
+
name,
|
|
64
|
+
current: get(name),
|
|
65
|
+
default: spec.def,
|
|
66
|
+
min: spec.min,
|
|
67
|
+
max: spec.max,
|
|
68
|
+
values: spec.values,
|
|
69
|
+
pinned: !!(spec.env && process.env[spec.env]),
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Dream-only writer. Applies ONE bounded change with provenance; returns the
|
|
74
|
+
// applied {knob, from, to} or null when rejected (unknown knob / no-op / env-pinned).
|
|
75
|
+
function set(name, value, { reason = "", evidence = "" } = {}) {
|
|
76
|
+
const spec = KNOBS[name];
|
|
77
|
+
if (!spec) return null;
|
|
78
|
+
if (spec.env && process.env[spec.env]) return null; // operator pin wins β don't fight it
|
|
79
|
+
const from = get(name);
|
|
80
|
+
const to = clampKnob(name, value);
|
|
81
|
+
if (to === from) return null;
|
|
82
|
+
const data = readFile();
|
|
83
|
+
data.knobs = data.knobs || {};
|
|
84
|
+
data.knobs[name] = to;
|
|
85
|
+
data.history = data.history || [];
|
|
86
|
+
data.history.push({ ts: new Date().toISOString(), knob: name, from, to, reason: String(reason).slice(0, 300), evidence: String(evidence).slice(0, 300) });
|
|
87
|
+
data.history = data.history.slice(-MAX_HISTORY);
|
|
88
|
+
write(data);
|
|
89
|
+
return { knob: name, from, to };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Revert the most recent change of a knob (auto-rollback path).
|
|
93
|
+
function revert(name, { reason = "" } = {}) {
|
|
94
|
+
const data = readFile();
|
|
95
|
+
if (!data.knobs || data.knobs[name] === undefined) return null;
|
|
96
|
+
const hist = (data.history || []).filter((h) => h.knob === name);
|
|
97
|
+
const last = hist[hist.length - 1];
|
|
98
|
+
if (!last) return null;
|
|
99
|
+
const from = data.knobs[name];
|
|
100
|
+
data.knobs[name] = clampKnob(name, last.from);
|
|
101
|
+
data.history.push({ ts: new Date().toISOString(), knob: name, from, to: data.knobs[name], reason: `rollback: ${reason}`.slice(0, 300) });
|
|
102
|
+
data.history = data.history.slice(-MAX_HISTORY);
|
|
103
|
+
write(data);
|
|
104
|
+
return { knob: name, from, to: data.knobs[name] };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function lastChange() {
|
|
108
|
+
const data = readFile();
|
|
109
|
+
const hist = (data.history || []).filter((h) => !/^rollback:/.test(h.reason || ""));
|
|
110
|
+
return hist[hist.length - 1] || null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function history() { return readFile().history || []; }
|
|
114
|
+
|
|
115
|
+
function write(data) {
|
|
116
|
+
try { fs.writeFileSync(TUNING_FILE, JSON.stringify(data, null, 2)); } catch (e) {}
|
|
117
|
+
_cache = null; _cacheMtime = -1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function _resetForTest() {
|
|
121
|
+
try { fs.rmSync(TUNING_FILE, { force: true }); } catch (e) {}
|
|
122
|
+
_cache = null; _cacheMtime = -1;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { TUNING_FILE, KNOBS, get, set, revert, lastChange, history, knobTable, clampKnob, _resetForTest };
|