File size: 23,448 Bytes
598644b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
"""
SQLite interface for paper cache, extraction results, and match outcomes.

Schema:
  papers:        arXiv metadata + pipeline status + citation metadata
  concepts:      extracted concepts per paper (from LLM deconstruction)
  reductions:    match results per concept (from compositional matching engine)
  formalism_kb:  cached KB entries for fast lookup (mirrors YAML)
  pipeline_runs: audit log of pipeline executions

Thread-safe: uses WAL mode. Single-writer by design (pipeline is sequential).
"""

from __future__ import annotations

import json
import sqlite3
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional

# ---------------------------------------------------------------------------
# Schema
# ---------------------------------------------------------------------------

SCHEMA_SQL = """
PRAGMA journal_mode=WAL;
PRAGMA foreign_keys=ON;

CREATE TABLE IF NOT EXISTS papers (
    arxiv_id        TEXT PRIMARY KEY,
    title           TEXT NOT NULL,
    abstract        TEXT NOT NULL,
    authors         TEXT NOT NULL,          -- JSON array of strings
    categories      TEXT NOT NULL,          -- JSON array of strings
    published       TEXT NOT NULL,          -- ISO 8601
    updated         TEXT NOT NULL,          -- ISO 8601
    pdf_url         TEXT,
    -- Pipeline status
    status          TEXT NOT NULL DEFAULT 'ingested',  -- ingested|triaged|extracted|matched|displayed|skipped|error
    triage_passed   INTEGER,               -- 1 = novelty claim detected, 0 = skipped, NULL = not triaged
    triage_reason   TEXT,                   -- why it passed or was skipped
    ingestion_ts    TEXT NOT NULL DEFAULT (datetime('now')),
    extraction_ts   TEXT,
    matching_ts     TEXT,
    error_message   TEXT,
    citation_count  INTEGER DEFAULT 0,
    citation_fetched_ts TEXT
);

CREATE TABLE IF NOT EXISTS concepts (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    paper_arxiv_id  TEXT NOT NULL REFERENCES papers(arxiv_id) ON DELETE CASCADE,
    name            TEXT NOT NULL,          -- the term the paper uses
    is_claimed_novel INTEGER NOT NULL DEFAULT 0,
    claimed_novelty_text TEXT,
    mathematical_operation TEXT NOT NULL,
    domain          TEXT,
    codomain        TEXT,
    objective       TEXT,
    constraints     TEXT,                   -- JSON array
    canonical_analog TEXT,
    deconstructive_move TEXT,
    confidence      TEXT NOT NULL DEFAULT 'low',  -- high|medium|low
    confidence_rationale TEXT,
    flags           TEXT,                   -- JSON array
    -- Extraction metadata
    extraction_json TEXT NOT NULL,          -- full concept JSON from LLM
    extracted_at    TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE IF NOT EXISTS reductions (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    concept_id      INTEGER NOT NULL REFERENCES concepts(id) ON DELETE CASCADE,
    paper_arxiv_id  TEXT NOT NULL REFERENCES papers(arxiv_id) ON DELETE CASCADE,
    concept_name    TEXT NOT NULL,
    result_type     TEXT NOT NULL,          -- identity|compositional|analogy|unknown|confused
    reduction       TEXT NOT NULL,          -- e.g., "Kernel CCA ∘ neuralize ∘ predict_in_codomain"
    canonical_analog TEXT,
    genuine_delta   TEXT,
    micro           TEXT,
    meso            TEXT,
    macro           TEXT,
    confidence      REAL NOT NULL DEFAULT 0.0,
    display         TEXT NOT NULL,          -- sous rature formatted string
    notes           TEXT,                   -- JSON array
    match_json      TEXT NOT NULL,          -- full MatchResult as JSON
    matched_at      TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE IF NOT EXISTS formalism_kb (
    id              TEXT PRIMARY KEY,       -- matches formalism.id in YAML
    name            TEXT NOT NULL,
    signature_json  TEXT NOT NULL,          -- JSON: {operation, domain, codomain, objective_family}
    meso_type       TEXT,
    macro_type      TEXT,
    canonical_reference TEXT,
    researchor_artifact_id TEXT,
    researchor_mental_model_id TEXT,
    status          TEXT NOT NULL DEFAULT 'seed',
    cached_at       TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE IF NOT EXISTS pipeline_runs (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    run_type        TEXT NOT NULL,          -- daily|single|retroactive|manual
    started_at      TEXT NOT NULL DEFAULT (datetime('now')),
    finished_at     TEXT,
    papers_ingested INTEGER DEFAULT 0,
    papers_triaged  INTEGER DEFAULT 0,
    papers_extracted INTEGER DEFAULT 0,
    papers_matched  INTEGER DEFAULT 0,
    papers_error    INTEGER DEFAULT 0,
    status          TEXT NOT NULL DEFAULT 'running',  -- running|completed|failed
    error_message   TEXT,
    config_json     TEXT                    -- snapshot of pipeline config at run time
);

CREATE INDEX IF NOT EXISTS idx_papers_status ON papers(status);
CREATE INDEX IF NOT EXISTS idx_papers_updated ON papers(updated);
CREATE INDEX IF NOT EXISTS idx_concepts_paper ON concepts(paper_arxiv_id);
CREATE INDEX IF NOT EXISTS idx_reductions_paper ON reductions(paper_arxiv_id);
CREATE INDEX IF NOT EXISTS idx_reductions_concept ON reductions(concept_id);
CREATE INDEX IF NOT EXISTS idx_reductions_type ON reductions(result_type);
CREATE INDEX IF NOT EXISTS idx_pipeline_runs_started ON pipeline_runs(started_at);

-- Migration: add citation columns (safe to run on existing DBs)
ALTER TABLE papers ADD COLUMN citation_count INTEGER DEFAULT 0;
ALTER TABLE papers ADD COLUMN citation_fetched_ts TEXT;
"""


# ---------------------------------------------------------------------------
# Database wrapper
# ---------------------------------------------------------------------------

@dataclass
class Database:
    """SQLite database interface for the Différance Engine pipeline."""

    path: Path
    _conn: sqlite3.Connection | None = field(default=None, repr=False, init=False)

    def __post_init__(self):
        self.path = Path(self.path)
        self.path.parent.mkdir(parents=True, exist_ok=True)

    def connect(self):
        """Open connection and ensure schema exists."""
        if self._conn is not None:
            return
        self._conn = sqlite3.connect(str(self.path))
        self._conn.row_factory = sqlite3.Row
        self._conn.executescript(SCHEMA_SQL)
        self._conn.commit()

    def close(self):
        if self._conn is not None:
            self._conn.close()
            self._conn = None

    def __enter__(self):
        self.connect()
        return self

    def __exit__(self, *args):
        self.close()

    # ---- Papers ----

    def paper_exists(self, arxiv_id: str) -> bool:
        self.connect()
        row = self._conn.execute("SELECT 1 FROM papers WHERE arxiv_id = ?", (arxiv_id,)).fetchone()
        return row is not None

    def insert_paper(self, paper: dict) -> bool:
        """Insert a paper from arXiv API parsed data. Returns True if new."""
        self.connect()
        if self.paper_exists(paper["arxiv_id"]):
            return False
        self._conn.execute(
            """INSERT INTO papers (arxiv_id, title, abstract, authors, categories,
               published, updated, pdf_url, status)
               VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'ingested')""",
            (
                paper["arxiv_id"],
                paper["title"],
                paper["abstract"],
                json.dumps(paper.get("authors", [])),
                json.dumps(paper.get("categories", [])),
                paper.get("published", ""),
                paper.get("updated", ""),
                paper.get("pdf_url", ""),
            ),
        )
        self._conn.commit()
        return True

    def update_triage(self, arxiv_id: str, passed: bool, reason: str = ""):
        self.connect()
        self._conn.execute(
            """UPDATE papers SET triage_passed = ?, triage_reason = ?,
               status = CASE WHEN ? THEN 'triaged' ELSE 'skipped' END
               WHERE arxiv_id = ?""",
            (1 if passed else 0, reason, passed, arxiv_id),
        )
        self._conn.commit()

    def update_status(self, arxiv_id: str, status: str, error: str = ""):
        self.connect()
        ts = datetime.now(timezone.utc).isoformat()
        field = {"extracted": "extraction_ts", "matched": "matching_ts",
                 "displayed": "matching_ts"}.get(status, "")
        if field:
            self._conn.execute(
                f"UPDATE papers SET status = ?, {field} = ?, error_message = ? WHERE arxiv_id = ?",
                (status, ts, error, arxiv_id),
            )
        else:
            self._conn.execute(
                "UPDATE papers SET status = ?, error_message = ? WHERE arxiv_id = ?",
                (status, error, arxiv_id),
            )
        self._conn.commit()

    def update_citation(self, arxiv_id: str, count: int):
        self.connect()
        try:
            self._conn.execute(
                "UPDATE papers SET citation_count = ?, citation_fetched_ts = ? WHERE arxiv_id = ?",
                (count, "now", arxiv_id),
            )
            self._conn.commit()
        except sqlite3.OperationalError:
            # Column might not exist yet
            try:
                self._conn.execute("ALTER TABLE papers ADD COLUMN citation_count INTEGER DEFAULT 0")
                self._conn.execute("ALTER TABLE papers ADD COLUMN citation_fetched_ts TEXT")
                self._conn.execute(
                    "UPDATE papers SET citation_count = ?, citation_fetched_ts = ? WHERE arxiv_id = ?",
                    (count, "now", arxiv_id),
                )
                self._conn.commit()
            except Exception:
                pass

    def get_papers_by_status(self, status: str, limit: int = 100) -> list[dict]:
        self.connect()
        rows = self._conn.execute(
            "SELECT * FROM papers WHERE status = ? ORDER BY updated DESC LIMIT ?",
            (status, limit),
        ).fetchall()
        return [_row_to_dict(r) for r in rows]

    def get_papers_needing_extraction(self, limit: int = 10) -> list[dict]:
        self.connect()
        rows = self._conn.execute(
            "SELECT * FROM papers WHERE status = 'triaged' AND triage_passed = 1 ORDER BY updated DESC LIMIT ?",
            (limit,),
        ).fetchall()
        return [_row_to_dict(r) for r in rows]

    def get_paper(self, arxiv_id: str) -> dict | None:
        self.connect()
        row = self._conn.execute("SELECT * FROM papers WHERE arxiv_id = ?", (arxiv_id,)).fetchone()
        return _row_to_dict(row) if row else None

    def find_paper(self, arxiv_id: str) -> dict | None:
        """Look up a paper by arXiv ID, trying version-suffix variations.

        arXiv IDs can be stored with version suffixes (e.g. 2301.07093v1)
        but users may query without them (2301.07093). This tries exact
        match first, then strips/adds version suffixes.
        """
        # 1. Exact match
        paper = self.get_paper(arxiv_id)
        if paper:
            return paper

        # 2. User provided no version — try v1, v2, v3
        import re
        if not re.search(r'v\d+$', arxiv_id):
            for v in range(1, 4):
                paper = self.get_paper(f"{arxiv_id}v{v}")
                if paper:
                    return paper
        else:
            # 3. User provided version — try stripping it
            base = re.sub(r'v\d+$', '', arxiv_id)
            paper = self.get_paper(base)
            if paper:
                return paper

        # 4. LIKE prefix match (finds any version of this paper)
        self.connect()
        row = self._conn.execute(
            "SELECT * FROM papers WHERE arxiv_id LIKE ? || '%' LIMIT 1",
            (arxiv_id,),
        ).fetchone()
        return _row_to_dict(row) if row else None

    def count_by_status(self) -> dict[str, int]:
        self.connect()
        rows = self._conn.execute(
            "SELECT status, COUNT(*) as cnt FROM papers GROUP BY status"
        ).fetchall()
        return {r["status"]: r["cnt"] for r in rows}

    # ---- Concepts ----

    def insert_concept(self, paper_arxiv_id: str, concept: dict):
        self.connect()
        self._conn.execute(
            """INSERT INTO concepts (paper_arxiv_id, name, is_claimed_novel,
               claimed_novelty_text, mathematical_operation, domain, codomain,
               objective, constraints, canonical_analog, deconstructive_move,
               confidence, confidence_rationale, flags, extraction_json)
               VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
            (
                paper_arxiv_id,
                concept.get("name", ""),
                1 if concept.get("is_claimed_novel") else 0,
                concept.get("claimed_novelty_text"),
                concept.get("mathematical_operation", ""),
                concept.get("domain"),
                concept.get("codomain"),
                concept.get("objective"),
                json.dumps(concept.get("constraints", [])),
                concept.get("canonical_analog"),
                concept.get("deconstructive_move"),
                concept.get("confidence", "low"),
                concept.get("confidence_rationale"),
                json.dumps(concept.get("flags", [])),
                json.dumps(concept),
            ),
        )
        self._conn.commit()
        return self._conn.execute("SELECT last_insert_rowid()").fetchone()[0]

    def delete_concepts_for_paper(self, arxiv_id: str):
        self.connect()
        self._conn.execute("DELETE FROM concepts WHERE paper_arxiv_id = ?", (arxiv_id,))
        self._conn.commit()

    # ---- Reductions ----

    def insert_reduction(self, concept_id: int, paper_arxiv_id: str, match: dict):
        self.connect()
        self._conn.execute(
            """INSERT INTO reductions (concept_id, paper_arxiv_id, concept_name,
               result_type, reduction, canonical_analog, genuine_delta, micro,
               meso, macro, confidence, display, notes, match_json)
               VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
            (
                concept_id, paper_arxiv_id,
                match.get("concept_name", ""),
                match.get("result_type", ""),
                match.get("reduction", ""),
                match.get("canonical_analog", ""),
                match.get("genuine_delta", ""),
                match.get("micro", ""),
                match.get("meso", ""),
                match.get("macro", ""),
                match.get("confidence", 0.0),
                match.get("display", ""),
                json.dumps(match.get("notes", [])),
                json.dumps(match),
            ),
        )
        self._conn.commit()

    def delete_reductions_for_paper(self, arxiv_id: str):
        self.connect()
        self._conn.execute("DELETE FROM reductions WHERE paper_arxiv_id = ?", (arxiv_id,))
        self._conn.commit()

    def get_reductions_for_paper(self, arxiv_id: str) -> list[dict]:
        self.connect()
        rows = self._conn.execute(
            "SELECT * FROM reductions WHERE paper_arxiv_id = ? ORDER BY id",
            (arxiv_id,),
        ).fetchall()
        return [_row_to_dict(r) for r in rows]

    def get_displayable_papers(self, limit: int = 50) -> list[dict]:
        """Papers ready for display: matched results exist."""
        self.connect()
        rows = self._conn.execute(
            """SELECT p.* FROM papers p
               WHERE p.status = 'matched'
               ORDER BY p.updated DESC LIMIT ?""",
            (limit,),
        ).fetchall()
        return [_row_to_dict(r) for r in rows]

    # ---- Cross-reference indexing ----

    def get_canonical_analogs(self) -> list[dict]:
        """All distinct canonical analogs cited, with paper counts."""
        self.connect()
        rows = self._conn.execute(
            """SELECT canonical_analog, COUNT(*) as paper_count,
                      GROUP_CONCAT(DISTINCT paper_arxiv_id) as paper_ids
               FROM reductions
               WHERE canonical_analog IS NOT NULL AND canonical_analog != ''
               GROUP BY canonical_analog
               ORDER BY paper_count DESC"""
        ).fetchall()
        return [_row_to_dict(r) for r in rows]

    def get_papers_by_analog(self, analog: str, limit: int = 20) -> list[dict]:
        """Papers that share a canonical analog (fuzzy match)."""
        self.connect()
        rows = self._conn.execute(
            """SELECT DISTINCT p.* FROM papers p
               JOIN reductions r ON r.paper_arxiv_id = p.arxiv_id
               WHERE r.canonical_analog LIKE ?
               ORDER BY p.updated DESC LIMIT ?""",
            (f"%{analog}%", limit),
        ).fetchall()
        return [_row_to_dict(r) for r in rows]

    def get_papers_by_move(self, move: str, limit: int = 20) -> list[dict]:
        """Papers whose concepts use a specific deconstructive move."""
        self.connect()
        rows = self._conn.execute(
            """SELECT DISTINCT p.*, c.deconstructive_move, c.name as concept_name
               FROM papers p
               JOIN concepts c ON c.paper_arxiv_id = p.arxiv_id
               WHERE c.deconstructive_move = ?
               ORDER BY p.updated DESC LIMIT ?""",
            (move, limit),
        ).fetchall()
        return [_row_to_dict(r) for r in rows]

    def get_move_counts(self) -> list[dict]:
        """Count of each deconstructive move across all concepts."""
        self.connect()
        rows = self._conn.execute(
            """SELECT deconstructive_move, COUNT(*) as cnt
               FROM concepts
               WHERE deconstructive_move IS NOT NULL
               GROUP BY deconstructive_move
               ORDER BY cnt DESC"""
        ).fetchall()
        return [_row_to_dict(r) for r in rows]

    def get_papers_with_unknown(self, limit: int = 100) -> list[dict]:
        """Papers with at least one UNKNOWN reduction (retroactive queue)."""
        self.connect()
        rows = self._conn.execute(
            """SELECT DISTINCT p.* FROM papers p
               JOIN reductions r ON r.paper_arxiv_id = p.arxiv_id
               WHERE r.result_type = 'unknown'
               ORDER BY p.updated DESC LIMIT ?""",
            (limit,),
        ).fetchall()
        return [_row_to_dict(r) for r in rows]

    # ---- KB cache ----

    def sync_kb_cache(self, formalisms: list[dict]):
        """Sync the cached KB table from the current formalisms list."""
        self.connect()
        self._conn.execute("DELETE FROM formalism_kb")
        for fm in formalisms:
            self._conn.execute(
                """INSERT OR REPLACE INTO formalism_kb
                   (id, name, signature_json, meso_type, macro_type,
                    canonical_reference, researchor_artifact_id,
                    researchor_mental_model_id, status)
                   VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
                (
                    fm["id"],
                    fm["name"],
                    json.dumps(fm.get("signature", {})),
                    fm.get("meso_type"),
                    fm.get("macro_type"),
                    fm.get("canonical_reference"),
                    fm.get("researchor_artifact_id"),
                    fm.get("researchor_mental_model_id"),
                    fm.get("status", "seed"),
                ),
            )
        self._conn.commit()

    # ---- Pipeline runs ----

    def start_run(self, run_type: str, config: dict | None = None) -> int:
        self.connect()
        cur = self._conn.execute(
            "INSERT INTO pipeline_runs (run_type, config_json) VALUES (?, ?)",
            (run_type, json.dumps(config) if config else "{}"),
        )
        self._conn.commit()
        return cur.lastrowid

    def finish_run(self, run_id: int, counts: dict, error: str = ""):
        self.connect()
        status = "failed" if error else "completed"
        self._conn.execute(
            """UPDATE pipeline_runs SET finished_at = ?, papers_ingested = ?,
               papers_triaged = ?, papers_extracted = ?, papers_matched = ?,
               papers_error = ?, status = ?, error_message = ?
               WHERE id = ?""",
            (
                datetime.now(timezone.utc).isoformat(),
                counts.get("ingested", 0),
                counts.get("triaged", 0),
                counts.get("extracted", 0),
                counts.get("matched", 0),
                counts.get("errors", 0),
                status,
                error,
                run_id,
            ),
        )
        self._conn.commit()

    def recent_runs(self, limit: int = 10) -> list[dict]:
        self.connect()
        rows = self._conn.execute(
            "SELECT * FROM pipeline_runs ORDER BY started_at DESC LIMIT ?",
            (limit,),
        ).fetchall()
        return [_row_to_dict(r) for r in rows]

    # ---- Stats ----

    def stats(self) -> dict:
        self.connect()
        counts = self.count_by_status()
        total = sum(counts.values())

        # Reduction type breakdown
        red_rows = self._conn.execute(
            "SELECT result_type, COUNT(*) as cnt FROM reductions GROUP BY result_type"
        ).fetchall()
        reduction_counts = {r["result_type"]: r["cnt"] for r in red_rows}

        # Top deconstructive moves
        move_rows = self._conn.execute(
            "SELECT deconstructive_move, COUNT(*) as cnt FROM concepts WHERE deconstructive_move IS NOT NULL GROUP BY deconstructive_move ORDER BY cnt DESC LIMIT 5"
        ).fetchall()

        return {
            "total_papers": total,
            "by_status": counts,
            "total_concepts": sum(
                r["cnt"] for r in self._conn.execute("SELECT COUNT(*) as cnt FROM concepts").fetchall()
            ),
            "total_reductions": sum(reduction_counts.values()),
            "reductions_by_type": reduction_counts,
            "reduction_rate": (
                (reduction_counts.get("identity", 0) + reduction_counts.get("compositional", 0))
                / max(sum(reduction_counts.values()), 1)
            ),
            "top_moves": [{"move": r["deconstructive_move"], "count": r["cnt"]} for r in move_rows],
            "recent_runs": self.recent_runs(3),
        }


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _row_to_dict(row: sqlite3.Row | None) -> dict | None:
    if row is None:
        return None
    d = dict(row)
    # Deserialize JSON fields
    for field in ("authors", "categories", "constraints", "flags", "notes"):
        if field in d and isinstance(d[field], str):
            try:
                d[field] = json.loads(d[field])
            except (json.JSONDecodeError, TypeError):
                pass
    return d


# ---------------------------------------------------------------------------
# Convenience
# ---------------------------------------------------------------------------

def get_db(path: Path | str | None = None) -> Database:
    """Get a Database instance for the default data directory."""
    if path is None:
        path = Path(__file__).resolve().parent.parent / "data" / "papers.db"
    return Database(Path(path))