@jeffober/thatch 0.1.18 → 0.1.19
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/README.md +54 -0
- package/bin/thatch +73 -7
- package/docs/in-progress/prediction-engine.md +404 -0
- package/docs/qa/README.md +33 -1
- package/docs/qa/use-cases/README.md +4 -0
- package/docs/qa/use-cases/UC-021-prediction-autofire.md +21 -0
- package/docs/qa/use-cases/UC-022-prediction-dedup.md +19 -0
- package/docs/qa/use-cases/UC-023-prediction-confidence-model.md +23 -0
- package/docs/qa/use-cases/UC-024-prediction-delete.md +20 -0
- package/package.json +1 -1
- package/src/db.ts +480 -0
- package/src/index.ts +54 -18
- package/src/prompts.ts +89 -5
- package/src/sideband.ts +51 -13
- package/src/tool-defs.ts +203 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# UC-022: Prediction create-on-existing (dedup + link)
|
|
2
|
+
|
|
3
|
+
**Preconditions**
|
|
4
|
+
- An opencode session with at least one existing prediction (from UC-021 or seeded directly)
|
|
5
|
+
- The existing prediction's matcher context is semantically similar to a new observation
|
|
6
|
+
|
|
7
|
+
**Steps**
|
|
8
|
+
|
|
9
|
+
1. Seed a prediction: `prediction_update(matcher="reviewing a PR", prediction="go through findings one at a time using a todo list", signal="create")`.
|
|
10
|
+
2. In the same or a new session, call `prediction_update(matcher="code review session", prediction="go through findings one at a time using a todo list", signal="create")`.
|
|
11
|
+
3. Call `prediction_update(matcher="code review session", prediction="go through findings one at a time using a todo list", signal="confirm")`.
|
|
12
|
+
4. Call `prediction_list` to inspect the model.
|
|
13
|
+
|
|
14
|
+
**Expected**
|
|
15
|
+
- Step 2: `findNearestPrediction` finds the existing prediction (cosine > 0.85). The tool returns `[linked]` (not `[created]`). The new matcher is linked via an edge. No duplicate prediction is created. Confidence is unchanged (create is confidence-neutral).
|
|
16
|
+
- Step 3: Same prediction is found. The matcher edge already exists (ON CONFLICT DO NOTHING). Confidence is adjusted upward by the confirm signal. Returns `[confirm]` with updated confidence.
|
|
17
|
+
- Step 4: `prediction_list` shows one prediction with two matchers ("reviewing a PR" and "code review session"), confidence reflecting one confirm, and provenance entries for both the create and confirm signals.
|
|
18
|
+
|
|
19
|
+
_Automatable: yes — the create-on-existing path is tested in `tool-defs.test.ts` (prediction_update create on existing prediction links without disconfirming). The dedup + edge + confidence behavior is verified with assertions on the return value and prediction_list output._
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# UC-023: Prediction confidence and signal model
|
|
2
|
+
|
|
3
|
+
**Preconditions**
|
|
4
|
+
- An opencode or Claude Code / Cursor session with the prediction model seeded
|
|
5
|
+
|
|
6
|
+
**Steps**
|
|
7
|
+
|
|
8
|
+
1. `prediction_update(matcher="X", prediction="Y", signal="create")` — seeds at p0 (0.50, 0 evidence).
|
|
9
|
+
2. `prediction_update(matcher="X", prediction="Y", signal="confirm")` — user confirmed.
|
|
10
|
+
3. `prediction_update(matcher="X", prediction="Y", signal="soft")` — user partially disagreed.
|
|
11
|
+
4. `prediction_update(matcher="X", prediction="Y", signal="disconfirm")` — user pushed back.
|
|
12
|
+
5. `prediction_list` to inspect confidence and evidence counts.
|
|
13
|
+
6. `prediction_query(context="X")` to query the model.
|
|
14
|
+
|
|
15
|
+
**Expected**
|
|
16
|
+
- Step 1: Returns `[created]`, confidence=0.50, counts (0/0).
|
|
17
|
+
- Step 2: Returns `[confirm]`, confidence > 0.50. Formula: (1 + 5*0.5) / (1 + 0 + 5) = 0.583. Counts (1/0).
|
|
18
|
+
- Step 3: Returns `[soft]`, confidence drops slightly. Soft adds 0.25 to disconfirm_count. Formula: (1 + 2.5) / (1 + 0.25 + 5) = 0.571. Counts (1/0.25).
|
|
19
|
+
- Step 4: Returns `[disconfirm]`, confidence drops further. Formula: (1 + 2.5) / (1 + 1.25 + 5) = 0.524. Counts (1/1.25).
|
|
20
|
+
- Step 5: `prediction_list` shows the prediction with accumulated provenance entries (create, confirm, soft, disconfirm) sorted newest-first.
|
|
21
|
+
- Step 6: `prediction_query` returns the prediction with the current confidence and evidence count. Uses "you may prefer" for 0-evidence and "you tend to" for predictions with evidence. Threshold (0.45) filters out matchers below the relevance floor.
|
|
22
|
+
|
|
23
|
+
_Automatable: yes — the confidence math and signal mapping are tested in `prediction.test.ts` (adjustConfidence confirm/disconfirm/soft/multiple) and `tool-defs.test.ts` (prediction_update confirm applies signal immediately)._
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# UC-024: Prediction delete and provenance inspection
|
|
2
|
+
|
|
3
|
+
**Preconditions**
|
|
4
|
+
- An opencode or Claude Code / Cursor session with predictions in the model
|
|
5
|
+
|
|
6
|
+
**Steps**
|
|
7
|
+
|
|
8
|
+
1. Seed two predictions in the same store.
|
|
9
|
+
2. `prediction_list` to see both predictions with matchers, confidence, and provenance.
|
|
10
|
+
3. `prediction_delete(statement="first prediction text")` to remove one.
|
|
11
|
+
4. `prediction_list` to verify only one prediction remains.
|
|
12
|
+
5. `prediction_delete(statement="nonexistent prediction")` to test not-found handling.
|
|
13
|
+
|
|
14
|
+
**Expected**
|
|
15
|
+
- Step 2: Both predictions listed with confidence, evidence count, matchers (with weights), and provenance entries (signal type, detail, date).
|
|
16
|
+
- Step 3: Returns `[deleted]` with the matched prediction's statement. Matching is semantic (cosine >= 0.85), not exact string match. Edges and provenance are cascade-deleted via FK ON DELETE CASCADE.
|
|
17
|
+
- Step 4: Only the remaining prediction is listed. No orphaned edges or provenance entries.
|
|
18
|
+
- Step 5: Returns "No prediction matching ... found" for a statement with no semantic match above threshold.
|
|
19
|
+
|
|
20
|
+
_Automatable: yes — prediction_delete is tested in `tool-defs.test.ts` (found and not-found cases), and deletePrediction cascade is tested in `prediction.test.ts` (cascade to edges and provenance, non-existent ID)._
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jeffober/thatch",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.19",
|
|
4
4
|
"description": "Persistent memory for AI coding agents — local embeddings, SQLite stores, zero config. Works with OpenCode (as a plugin), Claude Code (as an MCP server), and Cursor (as an MCP server).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
package/src/db.ts
CHANGED
|
@@ -34,6 +34,46 @@ export interface DedupCandidate {
|
|
|
34
34
|
score: number;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
export interface MatcherRow {
|
|
38
|
+
id: string;
|
|
39
|
+
store: string;
|
|
40
|
+
description: string;
|
|
41
|
+
embedding: Uint8Array | null;
|
|
42
|
+
model: string | null;
|
|
43
|
+
created_at: string;
|
|
44
|
+
updated_at: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface PredictionRow {
|
|
48
|
+
id: string;
|
|
49
|
+
store: string;
|
|
50
|
+
statement: string;
|
|
51
|
+
rationale: string | null;
|
|
52
|
+
confidence: number;
|
|
53
|
+
confirm_count: number;
|
|
54
|
+
disconfirm_count: number;
|
|
55
|
+
created_at: string;
|
|
56
|
+
updated_at: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface PredictionNudgeItem {
|
|
60
|
+
confidence: number;
|
|
61
|
+
evidence_count: number;
|
|
62
|
+
matcher_description: string;
|
|
63
|
+
statement: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ScoredPrediction {
|
|
67
|
+
matcher_id: string;
|
|
68
|
+
matcher_description: string;
|
|
69
|
+
prediction_id: string;
|
|
70
|
+
statement: string;
|
|
71
|
+
confidence: number;
|
|
72
|
+
evidence_count: number;
|
|
73
|
+
score: number;
|
|
74
|
+
rationale: string | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
37
77
|
/**
|
|
38
78
|
* SQLite-backed store for thatch. All stores live in a single database
|
|
39
79
|
* partitioned by a `store` column. Embeddings are raw Float32Array bytes
|
|
@@ -46,6 +86,12 @@ export class ThatchDB {
|
|
|
46
86
|
this.#db = new Database(path, { create: true });
|
|
47
87
|
this.#db.run("PRAGMA journal_mode = WAL");
|
|
48
88
|
this.#db.run("PRAGMA busy_timeout = 5000");
|
|
89
|
+
// Enforce FK constraints per connection. The prediction tables use
|
|
90
|
+
// ON DELETE CASCADE for edges and provenance; enabling this pragma
|
|
91
|
+
// ensures those cascades fire. Also enforces the entries table's
|
|
92
|
+
// existing FK to stores(name), which was declarative but unenforced
|
|
93
|
+
// before this branch.
|
|
94
|
+
this.#db.run("PRAGMA foreign_keys = ON");
|
|
49
95
|
this.#initSchema();
|
|
50
96
|
}
|
|
51
97
|
|
|
@@ -90,6 +136,57 @@ export class ThatchDB {
|
|
|
90
136
|
)
|
|
91
137
|
`);
|
|
92
138
|
|
|
139
|
+
this.#db.run(`
|
|
140
|
+
CREATE TABLE IF NOT EXISTS prediction_matchers (
|
|
141
|
+
id TEXT PRIMARY KEY,
|
|
142
|
+
store TEXT NOT NULL REFERENCES stores(name),
|
|
143
|
+
description TEXT NOT NULL,
|
|
144
|
+
embedding BLOB,
|
|
145
|
+
model TEXT,
|
|
146
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
|
147
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
|
148
|
+
)
|
|
149
|
+
`);
|
|
150
|
+
|
|
151
|
+
this.#db.run(`
|
|
152
|
+
CREATE TABLE IF NOT EXISTS predictions (
|
|
153
|
+
id TEXT PRIMARY KEY,
|
|
154
|
+
store TEXT NOT NULL REFERENCES stores(name),
|
|
155
|
+
statement TEXT NOT NULL,
|
|
156
|
+
rationale TEXT,
|
|
157
|
+
embedding BLOB,
|
|
158
|
+
model TEXT,
|
|
159
|
+
confidence REAL NOT NULL DEFAULT 0.5,
|
|
160
|
+
confirm_count REAL NOT NULL DEFAULT 0,
|
|
161
|
+
disconfirm_count REAL NOT NULL DEFAULT 0,
|
|
162
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
|
163
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
|
164
|
+
)
|
|
165
|
+
`);
|
|
166
|
+
|
|
167
|
+
this.#db.run(`
|
|
168
|
+
CREATE TABLE IF NOT EXISTS prediction_edges (
|
|
169
|
+
matcher_id TEXT NOT NULL,
|
|
170
|
+
prediction_id TEXT NOT NULL,
|
|
171
|
+
weight REAL NOT NULL DEFAULT 1.0,
|
|
172
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
|
173
|
+
PRIMARY KEY (matcher_id, prediction_id),
|
|
174
|
+
FOREIGN KEY (matcher_id) REFERENCES prediction_matchers(id) ON DELETE CASCADE,
|
|
175
|
+
FOREIGN KEY (prediction_id) REFERENCES predictions(id) ON DELETE CASCADE
|
|
176
|
+
)
|
|
177
|
+
`);
|
|
178
|
+
|
|
179
|
+
this.#db.run(`
|
|
180
|
+
CREATE TABLE IF NOT EXISTS prediction_provenance (
|
|
181
|
+
id TEXT PRIMARY KEY,
|
|
182
|
+
prediction_id TEXT NOT NULL,
|
|
183
|
+
signal TEXT NOT NULL,
|
|
184
|
+
detail TEXT,
|
|
185
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
|
186
|
+
FOREIGN KEY (prediction_id) REFERENCES predictions(id) ON DELETE CASCADE
|
|
187
|
+
)
|
|
188
|
+
`);
|
|
189
|
+
|
|
93
190
|
this.#db.run("INSERT OR IGNORE INTO stores (name) VALUES ('global')");
|
|
94
191
|
|
|
95
192
|
this.#migrateColumns();
|
|
@@ -456,6 +553,383 @@ export class ThatchDB {
|
|
|
456
553
|
return new Set(rows.map((r: any) => [r.slug_a, r.slug_b].sort().join("|")));
|
|
457
554
|
}
|
|
458
555
|
|
|
556
|
+
// ---------------------------------------------------------------------------
|
|
557
|
+
// Prediction engine: matchers, predictions, edges, provenance
|
|
558
|
+
// ---------------------------------------------------------------------------
|
|
559
|
+
|
|
560
|
+
// Bayesian confidence model constants. The posterior is:
|
|
561
|
+
// p = (confirm_count + K * P0) / (confirm_count + disconfirm_count + K)
|
|
562
|
+
// K is the prior strength (pseudo-evidence count): K=5 means 5
|
|
563
|
+
// pseudo-evidence "anchors" the prior. P0 is the prior probability
|
|
564
|
+
// (p=0.5 = "no preference either way"). W_SOFT is the fractional
|
|
565
|
+
// weight for a soft (weak) signal: 0.25 means a soft disconfirm counts
|
|
566
|
+
// as 1/4 of a full disconfirm.
|
|
567
|
+
static readonly PREDICTION_K = 5;
|
|
568
|
+
static readonly PREDICTION_P0 = 0.5;
|
|
569
|
+
static readonly PREDICTION_W_SOFT = 0.25;
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Brute-force cosine search over the matchers table for auto-fire.
|
|
573
|
+
* Returns matchers ranked by similarity to the query embedding,
|
|
574
|
+
* filtered by a noise floor (cosine >= 0.01) to exclude near-zero
|
|
575
|
+
* and negative scores, and by model-space compatibility (dimension
|
|
576
|
+
* match). Callers apply the actual relevance threshold (0.45 for
|
|
577
|
+
* auto-fire in index.ts, caller-specified for sideband).
|
|
578
|
+
*/
|
|
579
|
+
findMatchers(
|
|
580
|
+
stores: string[],
|
|
581
|
+
queryEmbedding: Float32Array,
|
|
582
|
+
opts?: { limit?: number },
|
|
583
|
+
): { id: string; description: string; score: number }[] {
|
|
584
|
+
if (stores.length === 0) return [];
|
|
585
|
+
const limit = opts?.limit ?? 5;
|
|
586
|
+
const placeholders = stores.map(() => "?").join(", ");
|
|
587
|
+
const rows = this.#db
|
|
588
|
+
.query(
|
|
589
|
+
`SELECT id, description, embedding FROM prediction_matchers
|
|
590
|
+
WHERE store IN (${placeholders}) AND embedding IS NOT NULL`,
|
|
591
|
+
)
|
|
592
|
+
.all(...(stores as [string, ...string[]])) as any[];
|
|
593
|
+
|
|
594
|
+
const scored: { id: string; description: string; score: number }[] = [];
|
|
595
|
+
for (const r of rows) {
|
|
596
|
+
const emb = blobToVector(r.embedding);
|
|
597
|
+
if (emb.length !== queryEmbedding.length) continue;
|
|
598
|
+
const score = cosineSimilarity(queryEmbedding, emb);
|
|
599
|
+
if (score >= 0.01) {
|
|
600
|
+
scored.push({ id: r.id, description: r.description, score: Math.round(score * 1000) / 1000 });
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
scored.sort((a, b) => b.score - a.score);
|
|
604
|
+
return scored.slice(0, limit);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Follows edges from matchers to scored predictions. Returns all
|
|
609
|
+
* predictions reachable from any matching matcher, ranked by
|
|
610
|
+
* cosine * weight * confidence.
|
|
611
|
+
*/
|
|
612
|
+
scorePredictions(
|
|
613
|
+
matchers: { id: string; description: string; score: number }[],
|
|
614
|
+
): ScoredPrediction[] {
|
|
615
|
+
if (matchers.length === 0) return [];
|
|
616
|
+
const matcherIds = matchers.map((m) => m.id);
|
|
617
|
+
const placeholders = matcherIds.map(() => "?").join(", ");
|
|
618
|
+
const rows = this.#db
|
|
619
|
+
.query(
|
|
620
|
+
`SELECT p.id, p.statement, p.rationale, p.confidence, p.confirm_count, p.disconfirm_count,
|
|
621
|
+
e.matcher_id, e.weight
|
|
622
|
+
FROM prediction_edges e
|
|
623
|
+
JOIN predictions p ON e.prediction_id = p.id
|
|
624
|
+
WHERE e.matcher_id IN (${placeholders})
|
|
625
|
+
ORDER BY p.statement`,
|
|
626
|
+
)
|
|
627
|
+
.all(...(matcherIds as [string, ...string[]])) as any[];
|
|
628
|
+
|
|
629
|
+
const matcherMap = new Map(matchers.map((m) => [m.id, m]));
|
|
630
|
+
const scored: ScoredPrediction[] = [];
|
|
631
|
+
for (const r of rows) {
|
|
632
|
+
const matcher = matcherMap.get(r.matcher_id);
|
|
633
|
+
if (!matcher) continue;
|
|
634
|
+
|
|
635
|
+
const confidence = r.confidence as number;
|
|
636
|
+
const evidence = Math.round(r.confirm_count + r.disconfirm_count);
|
|
637
|
+
const score = matcher.score * (r.weight as number) * confidence;
|
|
638
|
+
scored.push({
|
|
639
|
+
matcher_id: r.matcher_id,
|
|
640
|
+
matcher_description: matcher.description,
|
|
641
|
+
prediction_id: r.id,
|
|
642
|
+
statement: r.statement,
|
|
643
|
+
confidence: Math.round(confidence * 1000) / 1000,
|
|
644
|
+
evidence_count: evidence,
|
|
645
|
+
score: Math.round(score * 1000) / 1000,
|
|
646
|
+
rationale: r.rationale,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
scored.sort((a, b) => b.score - a.score);
|
|
650
|
+
|
|
651
|
+
// Dedup by prediction_id: multiple matchers may link to the same
|
|
652
|
+
// prediction via separate edges. Keep only the highest-scoring
|
|
653
|
+
// entry per prediction so the nudge doesn't repeat the same
|
|
654
|
+
// prediction with different matcher contexts.
|
|
655
|
+
const seen = new Set<string>();
|
|
656
|
+
return scored.filter((s) => {
|
|
657
|
+
if (seen.has(s.prediction_id)) return false;
|
|
658
|
+
seen.add(s.prediction_id);
|
|
659
|
+
return true;
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Full scoring pipeline for auto-fire and sideband: findMatchers,
|
|
665
|
+
* filter by threshold, scorePredictions (which includes dedup by
|
|
666
|
+
* prediction_id), slice, and map to PredictionNudgeItem. Shared by
|
|
667
|
+
* index.ts (auto-fire) and sideband.ts (MCP path) to prevent
|
|
668
|
+
* scoring-logic drift between the two host paths.
|
|
669
|
+
*/
|
|
670
|
+
scorePredictionNudge(
|
|
671
|
+
stores: string[],
|
|
672
|
+
embedding: Float32Array,
|
|
673
|
+
threshold: number,
|
|
674
|
+
limit = 5,
|
|
675
|
+
): PredictionNudgeItem[] {
|
|
676
|
+
const matchers = this.findMatchers(stores, embedding, { limit })
|
|
677
|
+
.filter((m) => m.score >= threshold);
|
|
678
|
+
if (matchers.length === 0) return [];
|
|
679
|
+
return this.scorePredictions(matchers)
|
|
680
|
+
.slice(0, limit)
|
|
681
|
+
.map((s) => ({
|
|
682
|
+
confidence: s.confidence,
|
|
683
|
+
evidence_count: s.evidence_count,
|
|
684
|
+
matcher_description: s.matcher_description,
|
|
685
|
+
statement: s.statement,
|
|
686
|
+
}));
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Finds the nearest matcher by cosine similarity above a caller-
|
|
691
|
+
* specified threshold. Used for dedup at prediction_update time
|
|
692
|
+
* (threshold 0.85), not auto-fire (use findMatchers for that).
|
|
693
|
+
*/
|
|
694
|
+
findNearestMatcher(
|
|
695
|
+
store: string,
|
|
696
|
+
embedding: Float32Array,
|
|
697
|
+
threshold: number,
|
|
698
|
+
): MatcherRow | null {
|
|
699
|
+
const rows = this.#db
|
|
700
|
+
.query("SELECT id, store, description, embedding, model, created_at, updated_at FROM prediction_matchers WHERE store = ? AND embedding IS NOT NULL")
|
|
701
|
+
.all(store) as any[];
|
|
702
|
+
|
|
703
|
+
let best: { row: any; score: number } | null = null;
|
|
704
|
+
for (const r of rows) {
|
|
705
|
+
const emb = blobToVector(r.embedding);
|
|
706
|
+
if (emb.length !== embedding.length) continue;
|
|
707
|
+
const score = cosineSimilarity(embedding, emb);
|
|
708
|
+
if (score >= threshold && (!best || score > best.score)) {
|
|
709
|
+
best = { row: r, score };
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (!best) return null;
|
|
713
|
+
return best.row as MatcherRow;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Creates a new matcher (context pattern) in the store, returning its id. */
|
|
717
|
+
createMatcher(
|
|
718
|
+
store: string,
|
|
719
|
+
description: string,
|
|
720
|
+
embedding: Float32Array,
|
|
721
|
+
model: string,
|
|
722
|
+
): string {
|
|
723
|
+
this.ensureStore(store);
|
|
724
|
+
const id = crypto.randomUUID();
|
|
725
|
+
const blob = new Uint8Array(embedding.buffer, embedding.byteOffset, embedding.byteLength);
|
|
726
|
+
const now = new Date().toISOString();
|
|
727
|
+
this.#db.run(
|
|
728
|
+
"INSERT INTO prediction_matchers (id, store, description, embedding, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
729
|
+
[id, store, description, blob, model, now, now] as any,
|
|
730
|
+
);
|
|
731
|
+
return id;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Finds the nearest prediction by cosine similarity across the
|
|
736
|
+
* entire store (not scoped to a single matcher's edges). This
|
|
737
|
+
* prevents duplicate predictions: when a new matcher is created,
|
|
738
|
+
* it can link to an existing prediction via a new edge rather
|
|
739
|
+
* than creating a second row with the same statement.
|
|
740
|
+
*/
|
|
741
|
+
findNearestPrediction(
|
|
742
|
+
store: string,
|
|
743
|
+
embedding: Float32Array,
|
|
744
|
+
threshold: number,
|
|
745
|
+
): PredictionRow | null {
|
|
746
|
+
const rows = this.#db
|
|
747
|
+
.query(
|
|
748
|
+
`SELECT id, store, statement, rationale, confidence, confirm_count, disconfirm_count, created_at, updated_at, embedding
|
|
749
|
+
FROM predictions
|
|
750
|
+
WHERE store = ? AND embedding IS NOT NULL`,
|
|
751
|
+
)
|
|
752
|
+
.all(store) as any[];
|
|
753
|
+
|
|
754
|
+
let best: { row: any; score: number } | null = null;
|
|
755
|
+
for (const r of rows) {
|
|
756
|
+
const emb = blobToVector(r.embedding);
|
|
757
|
+
if (emb.length !== embedding.length) continue;
|
|
758
|
+
const score = cosineSimilarity(embedding, emb);
|
|
759
|
+
if (score >= threshold && (!best || score > best.score)) {
|
|
760
|
+
best = { row: r, score };
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
if (!best) return null;
|
|
764
|
+
const { embedding: _, ...rest } = best.row;
|
|
765
|
+
return rest as PredictionRow;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Creates a new prediction, returning its id. Confidence is seeded
|
|
770
|
+
* at p0 (the population prior).
|
|
771
|
+
*/
|
|
772
|
+
createPrediction(
|
|
773
|
+
store: string,
|
|
774
|
+
statement: string,
|
|
775
|
+
rationale: string,
|
|
776
|
+
embedding: Float32Array,
|
|
777
|
+
model: string,
|
|
778
|
+
): string {
|
|
779
|
+
this.ensureStore(store);
|
|
780
|
+
const id = crypto.randomUUID();
|
|
781
|
+
const blob = new Uint8Array(embedding.buffer, embedding.byteOffset, embedding.byteLength);
|
|
782
|
+
const now = new Date().toISOString();
|
|
783
|
+
this.#db.run(
|
|
784
|
+
`INSERT INTO predictions (id, store, statement, rationale, embedding, model, confidence, confirm_count, disconfirm_count, created_at, updated_at)
|
|
785
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)`,
|
|
786
|
+
[id, store, statement, rationale, blob, model, ThatchDB.PREDICTION_P0, now, now] as any,
|
|
787
|
+
);
|
|
788
|
+
return id;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** Ensures an edge links a matcher to a prediction. Does not overwrite existing edge weight. */
|
|
792
|
+
createEdge(matcherId: string, predictionId: string, weight: number): void {
|
|
793
|
+
const now = new Date().toISOString();
|
|
794
|
+
this.#db.run(
|
|
795
|
+
`INSERT INTO prediction_edges (matcher_id, prediction_id, weight, created_at)
|
|
796
|
+
VALUES (?, ?, ?, ?)
|
|
797
|
+
ON CONFLICT(matcher_id, prediction_id) DO NOTHING`,
|
|
798
|
+
[matcherId, predictionId, weight, now] as any,
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Adjusts a prediction's confidence by applying a signal, then
|
|
804
|
+
* recomputing the Bayesian posterior. Mirrors samskara's
|
|
805
|
+
* samskara_apply_evaluation: discount prior evidence is NOT applied
|
|
806
|
+
* in v1 (no wall-clock decay), so counts accumulate monotonically.
|
|
807
|
+
*
|
|
808
|
+
* Signal mapping: "confirm" adds 1 to confirm_count; "disconfirm"
|
|
809
|
+
* adds 1 to disconfirm_count; "soft" adds W_SOFT (0.25) to
|
|
810
|
+
* disconfirm_count. The asymmetry (soft is a weak disconfirm, not
|
|
811
|
+
* a weak confirm) is intentional: a soft signal means the user
|
|
812
|
+
* partially disagreed, not partially agreed. There is no soft
|
|
813
|
+
* confirm; use "confirm" for weak agreement.
|
|
814
|
+
*/
|
|
815
|
+
adjustConfidence(predictionId: string, signal: "confirm" | "disconfirm" | "soft"): void {
|
|
816
|
+
const deltaConfirm = signal === "confirm" ? 1 : 0;
|
|
817
|
+
const deltaDisconfirm = signal === "disconfirm" ? 1 : (signal === "soft" ? ThatchDB.PREDICTION_W_SOFT : 0);
|
|
818
|
+
const k = ThatchDB.PREDICTION_K;
|
|
819
|
+
const p0 = ThatchDB.PREDICTION_P0;
|
|
820
|
+
const now = new Date().toISOString();
|
|
821
|
+
// Atomic UPDATE: uses the OLD column values in the confidence
|
|
822
|
+
// expression, so (confirm_count + deltaConfirm) equals the new
|
|
823
|
+
// confirm_count. No read-modify-write race between connections.
|
|
824
|
+
this.#db.run(
|
|
825
|
+
`UPDATE predictions
|
|
826
|
+
SET confirm_count = confirm_count + ?,
|
|
827
|
+
disconfirm_count = disconfirm_count + ?,
|
|
828
|
+
confidence = (confirm_count + ? + ? * ?) / (confirm_count + ? + disconfirm_count + ? + ?),
|
|
829
|
+
updated_at = ?
|
|
830
|
+
WHERE id = ?`,
|
|
831
|
+
[deltaConfirm, deltaDisconfirm, deltaConfirm, k, p0, deltaConfirm, deltaDisconfirm, k, now, predictionId] as any,
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Returns the population prior p0 for a store. Falls back to
|
|
837
|
+
* PREDICTION_P0 (0.5) when total evidence < 20; otherwise returns
|
|
838
|
+
* confirm_count / total_evidence. v2 feature: not yet wired into
|
|
839
|
+
* createPrediction, which uses flat PREDICTION_P0. v1 uses the
|
|
840
|
+
* flat prior until enough evidence exists to derive a population rate.
|
|
841
|
+
*/
|
|
842
|
+
populationP0(store: string): number {
|
|
843
|
+
const row = this.#db
|
|
844
|
+
.query(
|
|
845
|
+
`SELECT SUM(confirm_count) AS c, SUM(disconfirm_count) AS d FROM predictions WHERE store = ?`,
|
|
846
|
+
)
|
|
847
|
+
.get(store) as { c: number | null; d: number | null } | null;
|
|
848
|
+
const total = (row?.c ?? 0) + (row?.d ?? 0);
|
|
849
|
+
if (total < 20) return ThatchDB.PREDICTION_P0;
|
|
850
|
+
return row!.c! / total;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Returns a prediction's metadata by id. Does not return the
|
|
855
|
+
* embedding/model columns (creation-only, used by findNearestPrediction
|
|
856
|
+
* for dedup at write time, not needed for display or scoring).
|
|
857
|
+
*/
|
|
858
|
+
getPrediction(predictionId: string): PredictionRow | null {
|
|
859
|
+
const row = this.#db
|
|
860
|
+
.query(
|
|
861
|
+
"SELECT id, store, statement, rationale, confidence, confirm_count, disconfirm_count, created_at, updated_at FROM predictions WHERE id = ?",
|
|
862
|
+
)
|
|
863
|
+
.get(predictionId) as any;
|
|
864
|
+
if (!row) return null;
|
|
865
|
+
return row as PredictionRow;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Records a provenance entry (signal type + detail) for a prediction.
|
|
870
|
+
* Provenance is an audit trail for the inspector and agent.
|
|
871
|
+
*/
|
|
872
|
+
addProvenance(predictionId: string, signal: string, detail: string): void {
|
|
873
|
+
const id = crypto.randomUUID();
|
|
874
|
+
this.#db.run(
|
|
875
|
+
"INSERT INTO prediction_provenance (id, prediction_id, signal, detail) VALUES (?, ?, ?, ?)",
|
|
876
|
+
[id, predictionId, signal, detail] as any,
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/** Returns recent provenance entries for a prediction (newest first). */
|
|
881
|
+
getProvenance(predictionId: string): { signal: string; detail: string | null; created_at: string }[] {
|
|
882
|
+
return this.#db
|
|
883
|
+
.query("SELECT signal, detail, created_at FROM prediction_provenance WHERE prediction_id = ? ORDER BY rowid DESC LIMIT 10")
|
|
884
|
+
.all(predictionId) as any[];
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/** Deletes a prediction. Edges and provenance cascade via FK ON DELETE CASCADE. */
|
|
888
|
+
deletePrediction(predictionId: string): boolean {
|
|
889
|
+
const result = this.#db.run("DELETE FROM predictions WHERE id = ?", [predictionId]);
|
|
890
|
+
return result.changes > 0;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** Lists all predictions with their matchers, for the inspector. */
|
|
894
|
+
listPredictions(store: string): {
|
|
895
|
+
id: string;
|
|
896
|
+
statement: string;
|
|
897
|
+
rationale: string | null;
|
|
898
|
+
confidence: number;
|
|
899
|
+
evidence_count: number;
|
|
900
|
+
matchers: { id: string; description: string; weight: number }[];
|
|
901
|
+
}[] {
|
|
902
|
+
const predRows = this.#db
|
|
903
|
+
.query(
|
|
904
|
+
`SELECT id, statement, rationale, confidence, confirm_count, disconfirm_count
|
|
905
|
+
FROM predictions WHERE store = ? ORDER BY confidence DESC`,
|
|
906
|
+
)
|
|
907
|
+
.all(store) as any[];
|
|
908
|
+
|
|
909
|
+
return predRows.map((p) => {
|
|
910
|
+
const edgeRows = this.#db
|
|
911
|
+
.query(
|
|
912
|
+
`SELECT e.matcher_id, e.weight, m.description
|
|
913
|
+
FROM prediction_edges e
|
|
914
|
+
JOIN prediction_matchers m ON e.matcher_id = m.id
|
|
915
|
+
WHERE e.prediction_id = ?`,
|
|
916
|
+
)
|
|
917
|
+
.all(p.id) as any[];
|
|
918
|
+
return {
|
|
919
|
+
id: p.id,
|
|
920
|
+
statement: p.statement,
|
|
921
|
+
rationale: p.rationale,
|
|
922
|
+
confidence: Math.round(p.confidence * 1000) / 1000,
|
|
923
|
+
evidence_count: Math.round(p.confirm_count + p.disconfirm_count),
|
|
924
|
+
matchers: edgeRows.map((e) => ({
|
|
925
|
+
id: e.matcher_id,
|
|
926
|
+
description: e.description,
|
|
927
|
+
weight: e.weight,
|
|
928
|
+
})),
|
|
929
|
+
};
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
|
|
459
933
|
// ---------------------------------------------------------------------------
|
|
460
934
|
// Hygiene — signals for the session-start heartbeat. Staleness means
|
|
461
935
|
// neither written nor recalled since the cutoff; recall telemetry keeps
|
|
@@ -501,6 +975,12 @@ export class ThatchDB {
|
|
|
501
975
|
);
|
|
502
976
|
}
|
|
503
977
|
|
|
978
|
+
/** Runs fn inside a SQLite transaction (BEGIN/COMMIT/ROLLBACK). */
|
|
979
|
+
transaction<T>(fn: () => T): T {
|
|
980
|
+
const t = this.#db.transaction(fn);
|
|
981
|
+
return t();
|
|
982
|
+
}
|
|
983
|
+
|
|
504
984
|
close(): void {
|
|
505
985
|
this.#db.close();
|
|
506
986
|
}
|