blitz-cli 0.12.0__tar.gz → 0.14.0__tar.gz

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.
Files changed (119) hide show
  1. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/PKG-INFO +34 -3
  2. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/README.md +33 -2
  3. blitz_cli-0.14.0/blitz_cli/_collect.py +509 -0
  4. blitz_cli-0.14.0/blitz_cli/_collect_http.py +241 -0
  5. blitz_cli-0.14.0/blitz_cli/_collect_server.py +217 -0
  6. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/_execute.py +269 -38
  7. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/cli.py +266 -2
  8. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/playbooks/commands/implement.md +8 -1
  9. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/playbooks/commands/tasks.md +1 -1
  10. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/templates/locker_room_Makefile.tmpl +1 -1
  11. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/templates/locker_room_README.md.tmpl +2 -2
  12. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/templates/locker_room_compose.yml.tmpl +15 -2
  13. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/pyproject.toml +1 -1
  14. blitz_cli-0.14.0/specs/004-dpo-collect-pairs/checklists/requirements.md +36 -0
  15. blitz_cli-0.14.0/specs/004-dpo-collect-pairs/contracts/cli.md +52 -0
  16. blitz_cli-0.14.0/specs/004-dpo-collect-pairs/contracts/export-format.md +21 -0
  17. blitz_cli-0.14.0/specs/004-dpo-collect-pairs/data-model.md +105 -0
  18. blitz_cli-0.14.0/specs/004-dpo-collect-pairs/plan.md +112 -0
  19. blitz_cli-0.14.0/specs/004-dpo-collect-pairs/quickstart.md +88 -0
  20. blitz_cli-0.14.0/specs/004-dpo-collect-pairs/research.md +70 -0
  21. blitz_cli-0.14.0/specs/004-dpo-collect-pairs/spec.md +140 -0
  22. blitz_cli-0.14.0/specs/004-dpo-collect-pairs/tasks.md +156 -0
  23. blitz_cli-0.14.0/specs/005-collect-ingest-endpoint/checklists/requirements.md +35 -0
  24. blitz_cli-0.14.0/specs/005-collect-ingest-endpoint/contracts/client-sink.md +41 -0
  25. blitz_cli-0.14.0/specs/005-collect-ingest-endpoint/contracts/ingest-api.md +51 -0
  26. blitz_cli-0.14.0/specs/005-collect-ingest-endpoint/data-model.md +83 -0
  27. blitz_cli-0.14.0/specs/005-collect-ingest-endpoint/plan.md +108 -0
  28. blitz_cli-0.14.0/specs/005-collect-ingest-endpoint/quickstart.md +60 -0
  29. blitz_cli-0.14.0/specs/005-collect-ingest-endpoint/research.md +123 -0
  30. blitz_cli-0.14.0/specs/005-collect-ingest-endpoint/spec.md +137 -0
  31. blitz_cli-0.14.0/specs/005-collect-ingest-endpoint/tasks.md +105 -0
  32. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/tests/test_cli.py +120 -0
  33. blitz_cli-0.14.0/tests/test_collect.py +358 -0
  34. blitz_cli-0.14.0/tests/test_collect_http.py +315 -0
  35. blitz_cli-0.14.0/tests/test_collect_server.py +239 -0
  36. blitz_cli-0.14.0/tests/test_execute.py +708 -0
  37. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/tests/test_ontology.py +2 -2
  38. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/tests/test_scaffold.py +5 -4
  39. blitz_cli-0.12.0/tests/test_execute.py +0 -336
  40. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/.blitz/memory/playbook.md +0 -0
  41. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/.blitz/scripts/bash/check-prerequisites.sh +0 -0
  42. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/.blitz/scripts/bash/common.sh +0 -0
  43. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/.blitz/scripts/bash/create-new-feature.sh +0 -0
  44. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/.blitz/scripts/bash/setup-plan.sh +0 -0
  45. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/.blitz/scripts/bash/setup-tasks.sh +0 -0
  46. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/.blitz/templates/plan-template.md +0 -0
  47. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/.blitz/templates/spec-template.md +0 -0
  48. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/.blitz/templates/tasks-template.md +0 -0
  49. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/.github/workflows/homebrew.yml +0 -0
  50. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/.github/workflows/publish.yml +0 -0
  51. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/.gitignore +0 -0
  52. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/.mcp.json +0 -0
  53. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/LICENSE +0 -0
  54. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/__init__.py +0 -0
  55. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/_activity.py +0 -0
  56. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/_code_graph.py +0 -0
  57. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/_corpus.py +0 -0
  58. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/_dataset.py +0 -0
  59. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/_git.py +0 -0
  60. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/_gitignore.py +0 -0
  61. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/_make.py +0 -0
  62. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/_ontology.py +0 -0
  63. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/_playbooks.py +0 -0
  64. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/_redact.py +0 -0
  65. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/_scaffold.py +0 -0
  66. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/_tool_schemas.py +0 -0
  67. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/_train.py +0 -0
  68. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/playbooks/__init__.py +0 -0
  69. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/playbooks/commands/__init__.py +0 -0
  70. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/playbooks/commands/analyze.md +0 -0
  71. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/playbooks/commands/clarify.md +0 -0
  72. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/playbooks/commands/plan.md +0 -0
  73. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/playbooks/commands/playbook.md +0 -0
  74. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/playbooks/commands/specify.md +0 -0
  75. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/playbooks/scaffold/.blitz/memory/playbook.md +0 -0
  76. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/playbooks/scaffold/.blitz/scripts/bash/check-prerequisites.sh +0 -0
  77. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/playbooks/scaffold/.blitz/scripts/bash/common.sh +0 -0
  78. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/playbooks/scaffold/.blitz/scripts/bash/create-new-feature.sh +0 -0
  79. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/playbooks/scaffold/.blitz/scripts/bash/setup-plan.sh +0 -0
  80. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/playbooks/scaffold/.blitz/scripts/bash/setup-tasks.sh +0 -0
  81. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/playbooks/scaffold/.blitz/templates/plan-template.md +0 -0
  82. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/playbooks/scaffold/.blitz/templates/spec-template.md +0 -0
  83. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/playbooks/scaffold/.blitz/templates/tasks-template.md +0 -0
  84. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/playbooks/workspace/__init__.py +0 -0
  85. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/playbooks/workspace/huddle.md +0 -0
  86. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/templates/__init__.py +0 -0
  87. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/blitz_cli/templates/workspace_playbook.md.tmpl +0 -0
  88. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/specs/001-cli-model-namespace/checklists/requirements.md +0 -0
  89. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/specs/001-cli-model-namespace/contracts/cli-commands.md +0 -0
  90. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/specs/001-cli-model-namespace/plan.md +0 -0
  91. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/specs/001-cli-model-namespace/quickstart.md +0 -0
  92. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/specs/001-cli-model-namespace/research.md +0 -0
  93. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/specs/001-cli-model-namespace/spec.md +0 -0
  94. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/specs/001-cli-model-namespace/tasks.md +0 -0
  95. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/specs/002-label-experimental-surfaces/checklists/requirements.md +0 -0
  96. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/specs/002-label-experimental-surfaces/contracts/experimental-labels.md +0 -0
  97. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/specs/002-label-experimental-surfaces/data-model.md +0 -0
  98. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/specs/002-label-experimental-surfaces/plan.md +0 -0
  99. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/specs/002-label-experimental-surfaces/quickstart.md +0 -0
  100. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/specs/002-label-experimental-surfaces/research.md +0 -0
  101. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/specs/002-label-experimental-surfaces/spec.md +0 -0
  102. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/specs/002-label-experimental-surfaces/tasks.md +0 -0
  103. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/specs/003-init-gitignore-rules/checklists/requirements.md +0 -0
  104. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/specs/003-init-gitignore-rules/contracts/cli-output.md +0 -0
  105. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/specs/003-init-gitignore-rules/contracts/ensure-rules.md +0 -0
  106. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/specs/003-init-gitignore-rules/contracts/gitignore-block.md +0 -0
  107. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/specs/003-init-gitignore-rules/data-model.md +0 -0
  108. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/specs/003-init-gitignore-rules/plan.md +0 -0
  109. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/specs/003-init-gitignore-rules/quickstart.md +0 -0
  110. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/specs/003-init-gitignore-rules/research.md +0 -0
  111. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/specs/003-init-gitignore-rules/spec.md +0 -0
  112. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/specs/003-init-gitignore-rules/tasks.md +0 -0
  113. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/tests/test_activity.py +0 -0
  114. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/tests/test_corpus.py +0 -0
  115. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/tests/test_gitignore.py +0 -0
  116. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/tests/test_make.py +0 -0
  117. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/tests/test_playbooks.py +0 -0
  118. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/tests/test_redact.py +0 -0
  119. {blitz_cli-0.12.0 → blitz_cli-0.14.0}/tests/test_tool_schemas.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: blitz-cli
3
- Version: 0.12.0
3
+ Version: 0.14.0
4
4
  Summary: Developer CLI for Blitz locker-room: spec-driven playbooks, a codebase/huddle ontology graph with an MCP tool, and the composable watcher/trainer/graph containers.
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -75,6 +75,37 @@ Tasks whose text says "optional manual" are skipped and left unchecked. The
75
75
  Ollama endpoint comes from `--ollama-url` or `OLLAMA_HOST`; the model from
76
76
  `--model` or `BLITZ_EXECUTE_MODEL`.
77
77
 
78
+ ### Collection over the ingest endpoint
79
+
80
+ `blitz model execute --collect` records every generation attempt as DPO
81
+ training data. By default it writes straight to the activity SQLite database
82
+ (`ACTIVITY_DB_PATH`, else `data/activity.db`). With `COLLECT_ENDPOINT` set,
83
+ the same writes go over HTTP JSON to a collect ingest server instead, so the
84
+ collector needs no filesystem access to the database:
85
+
86
+ ```bash
87
+ # on the locker-room node (default port 1739, binds all interfaces, no auth):
88
+ ACTIVITY_DB_PATH=data/activity.db blitz locker-room collect-serve
89
+
90
+ # on the collecting machine:
91
+ COLLECT_ENDPOINT=http://locker-room-host:1739 blitz model execute --collect
92
+ ```
93
+
94
+ Each attempt is acknowledged by the endpoint before the next model call
95
+ starts. If the endpoint is unreachable when the run starts, the run fails
96
+ fast, same as an unwritable database. If it fails mid-run, the run keeps
97
+ going: undelivered payloads are buffered to `collect-buffer.jsonl` in the run
98
+ directory and the final output names the file. Deliver them later with
99
+
100
+ ```bash
101
+ blitz model collect flush .blitz/execute/runs/<stamp>/collect-buffer.jsonl
102
+ ```
103
+
104
+ Flushing is idempotent (the server deduplicates attempts on their
105
+ run/task/sample/attempt identity), so re-running a flush, or flushing rows the
106
+ server already received, never duplicates data. `pairs extract/export/stats`
107
+ read the database directly, unaffected by the endpoint.
108
+
78
109
  ## Locker-room: ontology graph, watcher, trainer, MCP
79
110
 
80
111
  The locker-room is a **context node**: a set of containers that ingest, build,
@@ -110,9 +141,9 @@ diff links it to the code it touches (`Feature -TOUCHES-> File/Symbol`), so an
110
141
  agent can walk a spec out to the full lineage of code it implements, and back.
111
142
 
112
143
  The `mcp` role serves this: point an agent's MCP config at
113
- `http://<node>:8848/sse` (tools `list_repos`, `get_repo`, `list_huddles`,
144
+ `http://<node>:1738/sse` (tools `list_repos`, `get_repo`, `list_huddles`,
114
145
  `get_huddle`, `get_playbooks`, `get_lineage`, `impact_of`, `get_file`,
115
- `get_symbol`, `neighbors`), or open `http://<node>:8848/graph` in a browser.
146
+ `get_symbol`, `neighbors`), or open `http://<node>:1738/graph` in a browser.
116
147
 
117
148
  ### Training (optional)
118
149
 
@@ -56,6 +56,37 @@ Tasks whose text says "optional manual" are skipped and left unchecked. The
56
56
  Ollama endpoint comes from `--ollama-url` or `OLLAMA_HOST`; the model from
57
57
  `--model` or `BLITZ_EXECUTE_MODEL`.
58
58
 
59
+ ### Collection over the ingest endpoint
60
+
61
+ `blitz model execute --collect` records every generation attempt as DPO
62
+ training data. By default it writes straight to the activity SQLite database
63
+ (`ACTIVITY_DB_PATH`, else `data/activity.db`). With `COLLECT_ENDPOINT` set,
64
+ the same writes go over HTTP JSON to a collect ingest server instead, so the
65
+ collector needs no filesystem access to the database:
66
+
67
+ ```bash
68
+ # on the locker-room node (default port 1739, binds all interfaces, no auth):
69
+ ACTIVITY_DB_PATH=data/activity.db blitz locker-room collect-serve
70
+
71
+ # on the collecting machine:
72
+ COLLECT_ENDPOINT=http://locker-room-host:1739 blitz model execute --collect
73
+ ```
74
+
75
+ Each attempt is acknowledged by the endpoint before the next model call
76
+ starts. If the endpoint is unreachable when the run starts, the run fails
77
+ fast, same as an unwritable database. If it fails mid-run, the run keeps
78
+ going: undelivered payloads are buffered to `collect-buffer.jsonl` in the run
79
+ directory and the final output names the file. Deliver them later with
80
+
81
+ ```bash
82
+ blitz model collect flush .blitz/execute/runs/<stamp>/collect-buffer.jsonl
83
+ ```
84
+
85
+ Flushing is idempotent (the server deduplicates attempts on their
86
+ run/task/sample/attempt identity), so re-running a flush, or flushing rows the
87
+ server already received, never duplicates data. `pairs extract/export/stats`
88
+ read the database directly, unaffected by the endpoint.
89
+
59
90
  ## Locker-room: ontology graph, watcher, trainer, MCP
60
91
 
61
92
  The locker-room is a **context node**: a set of containers that ingest, build,
@@ -91,9 +122,9 @@ diff links it to the code it touches (`Feature -TOUCHES-> File/Symbol`), so an
91
122
  agent can walk a spec out to the full lineage of code it implements, and back.
92
123
 
93
124
  The `mcp` role serves this: point an agent's MCP config at
94
- `http://<node>:8848/sse` (tools `list_repos`, `get_repo`, `list_huddles`,
125
+ `http://<node>:1738/sse` (tools `list_repos`, `get_repo`, `list_huddles`,
95
126
  `get_huddle`, `get_playbooks`, `get_lineage`, `impact_of`, `get_file`,
96
- `get_symbol`, `neighbors`), or open `http://<node>:8848/graph` in a browser.
127
+ `get_symbol`, `neighbors`), or open `http://<node>:1738/graph` in a browser.
97
128
 
98
129
  ### Training (optional)
99
130
 
@@ -0,0 +1,509 @@
1
+ """DPO data-collection store and preference-pair tooling for
2
+ `blitz model execute --collect` and the `blitz model pairs` command family.
3
+
4
+ `CollectStore` owns four tables (`collect_runs`, `collect_attempts`,
5
+ `dpo_pairs`, `sft_queue`) in the same locker-room activity database that
6
+ `_corpus.py`'s `TurnStore` writes its transcript corpus to (default
7
+ `data/activity.db`, env override `ACTIVITY_DB_PATH`, WAL journal). Like
8
+ `TurnStore`, it is the one writer of its tables and creates its schema on
9
+ first connect.
10
+
11
+ The execute loop inserts one `collect_runs` row per `--collect` invocation
12
+ and one `collect_attempts` row per model call, capturing the exact prompt
13
+ messages sent to the model verbatim. `extract_pairs` turns those attempts
14
+ into chosen/rejected DPO preference pairs (pass/fail contrast on byte
15
+ identical prompt lineage, or judge-ranked green-vs-green), `export_pairs`
16
+ emits TRL-shaped JSONL, and `stats` reports collection health per run.
17
+ `sft_queue` quarantines placeholders for tasks no chain solved; it never
18
+ feeds pairs.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import sqlite3
26
+ from datetime import datetime, timezone
27
+ from pathlib import Path
28
+ from typing import Optional
29
+
30
+ _SCHEMA = """
31
+ CREATE TABLE IF NOT EXISTS collect_runs (
32
+ run_id TEXT PRIMARY KEY,
33
+ repo_path TEXT NOT NULL,
34
+ spec TEXT NOT NULL,
35
+ tasks_file TEXT NOT NULL,
36
+ model TEXT NOT NULL,
37
+ endpoint TEXT NOT NULL,
38
+ temperature REAL NOT NULL,
39
+ max_retries INTEGER NOT NULL,
40
+ k INTEGER NOT NULL,
41
+ started_at TEXT NOT NULL,
42
+ finished_at TEXT
43
+ );
44
+ CREATE INDEX IF NOT EXISTS idx_collect_runs_started_at ON collect_runs(started_at);
45
+
46
+ CREATE TABLE IF NOT EXISTS collect_attempts (
47
+ attempt_id INTEGER PRIMARY KEY AUTOINCREMENT,
48
+ run_id TEXT NOT NULL REFERENCES collect_runs(run_id),
49
+ task_id TEXT NOT NULL,
50
+ sample_index INTEGER NOT NULL,
51
+ attempt_index INTEGER NOT NULL,
52
+ prompt_messages_json TEXT NOT NULL,
53
+ reply TEXT NOT NULL,
54
+ actions_json TEXT NOT NULL,
55
+ passed INTEGER NOT NULL,
56
+ error_detail TEXT,
57
+ guard_violation TEXT,
58
+ input_tokens INTEGER,
59
+ output_tokens INTEGER,
60
+ env_failure INTEGER NOT NULL DEFAULT 0,
61
+ judge_rank INTEGER,
62
+ judge_notes TEXT,
63
+ created_at TEXT NOT NULL,
64
+ UNIQUE(run_id, task_id, sample_index, attempt_index)
65
+ );
66
+ CREATE INDEX IF NOT EXISTS idx_attempts_task_lineage
67
+ ON collect_attempts(task_id, prompt_messages_json);
68
+ CREATE INDEX IF NOT EXISTS idx_attempts_run ON collect_attempts(run_id);
69
+
70
+ CREATE TABLE IF NOT EXISTS dpo_pairs (
71
+ pair_id INTEGER PRIMARY KEY AUTOINCREMENT,
72
+ chosen_attempt_id INTEGER NOT NULL REFERENCES collect_attempts(attempt_id),
73
+ rejected_attempt_id INTEGER NOT NULL REFERENCES collect_attempts(attempt_id),
74
+ task_id TEXT NOT NULL,
75
+ chosen_source TEXT NOT NULL CHECK (chosen_source IN ('local', 'judge-ranked')),
76
+ status TEXT NOT NULL DEFAULT 'candidate'
77
+ CHECK (status IN ('candidate', 'approved', 'rejected')),
78
+ judge_notes TEXT,
79
+ created_at TEXT NOT NULL,
80
+ UNIQUE(chosen_attempt_id, rejected_attempt_id),
81
+ CHECK (chosen_attempt_id != rejected_attempt_id)
82
+ );
83
+
84
+ CREATE TABLE IF NOT EXISTS sft_queue (
85
+ item_id INTEGER PRIMARY KEY AUTOINCREMENT,
86
+ run_id TEXT NOT NULL REFERENCES collect_runs(run_id),
87
+ task_id TEXT NOT NULL,
88
+ prompt_messages_json TEXT NOT NULL,
89
+ solution_text TEXT,
90
+ source TEXT NOT NULL DEFAULT 'placeholder',
91
+ status TEXT NOT NULL DEFAULT 'needs_solution',
92
+ created_at TEXT NOT NULL,
93
+ UNIQUE(run_id, task_id)
94
+ );
95
+ """
96
+
97
+
98
+ # Sentinel spec value for stub runs the ingest server creates when a write
99
+ # references a run_id it has no row for (for example the server restarted onto
100
+ # a fresh database mid-run). Flushes must survive server-side data loss, so
101
+ # ingestion never rejects a write for an unknown run; the sentinel keeps
102
+ # recovered runs visible in stats output.
103
+ RECOVERED_SPEC = "__recovered__"
104
+
105
+ # collect_runs metadata columns in insert order, shared by insert/ingest paths
106
+ # and by the ingest server's payload validation (payload fields mirror these
107
+ # column names 1:1; started_at/finished_at are server-owned).
108
+ RUN_FIELDS = (
109
+ "run_id", "repo_path", "spec", "tasks_file", "model", "endpoint",
110
+ "temperature", "max_retries", "k",
111
+ )
112
+
113
+ # collect_attempts client-sent columns. attempt_id and created_at are
114
+ # server-owned; env_failure/judge_* are judge-side and never client-sent.
115
+ ATTEMPT_REQUIRED_FIELDS = (
116
+ "run_id", "task_id", "sample_index", "attempt_index",
117
+ "prompt_messages_json", "reply", "actions_json", "passed",
118
+ )
119
+ ATTEMPT_OPTIONAL_FIELDS = (
120
+ "error_detail", "guard_violation", "input_tokens", "output_tokens",
121
+ )
122
+
123
+ SFT_FIELDS = ("run_id", "task_id", "prompt_messages_json")
124
+
125
+
126
+ def default_db_path() -> Path:
127
+ """The activity database path: ACTIVITY_DB_PATH env override, else the
128
+ conventional data/activity.db relative to the CWD (same idiom as _train.py)."""
129
+ return Path(os.environ.get("ACTIVITY_DB_PATH", "data/activity.db"))
130
+
131
+
132
+ def _now_iso() -> str:
133
+ return datetime.now(timezone.utc).isoformat()
134
+
135
+
136
+ class CollectStore:
137
+ """Writer for the DPO collection tables. Owns its schema and creates it on
138
+ first connect, the same writer-owns-schema pattern as `_corpus.TurnStore`,
139
+ against the same database file."""
140
+
141
+ def __init__(self, db_path: Path):
142
+ self._db_path = Path(db_path)
143
+ self._schema_ready = False
144
+
145
+ def _connect(self) -> sqlite3.Connection:
146
+ self._db_path.parent.mkdir(parents=True, exist_ok=True)
147
+ conn = sqlite3.connect(self._db_path)
148
+ conn.row_factory = sqlite3.Row
149
+ if not self._schema_ready:
150
+ conn.execute("PRAGMA journal_mode=WAL")
151
+ conn.executescript(_SCHEMA)
152
+ conn.commit()
153
+ self._schema_ready = True
154
+ return conn
155
+
156
+ @property
157
+ def db_path(self) -> Path:
158
+ return self._db_path
159
+
160
+ def check_writable(self) -> None:
161
+ """Create the schema and probe write access with BEGIN IMMEDIATE.
162
+ Raises sqlite3.OperationalError or OSError when the database is
163
+ unwritable or locked, so a --collect run can fail loudly before any
164
+ task runs instead of dropping attempt rows mid-run."""
165
+ conn = self._connect()
166
+ try:
167
+ conn.execute("BEGIN IMMEDIATE")
168
+ conn.execute("ROLLBACK")
169
+ finally:
170
+ conn.close()
171
+
172
+ def insert_run(
173
+ self,
174
+ run_id: str,
175
+ repo_path: str,
176
+ spec: str,
177
+ tasks_file: str,
178
+ model: str,
179
+ endpoint: str,
180
+ temperature: float,
181
+ max_retries: int,
182
+ k: int,
183
+ ) -> None:
184
+ conn = self._connect()
185
+ try:
186
+ conn.execute(
187
+ "INSERT INTO collect_runs (run_id, repo_path, spec, tasks_file, model, "
188
+ "endpoint, temperature, max_retries, k, started_at) "
189
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
190
+ (run_id, repo_path, spec, tasks_file, model, endpoint,
191
+ temperature, max_retries, k, _now_iso()),
192
+ )
193
+ conn.commit()
194
+ finally:
195
+ conn.close()
196
+
197
+ def finish_run(self, run_id: str) -> None:
198
+ conn = self._connect()
199
+ try:
200
+ conn.execute(
201
+ "UPDATE collect_runs SET finished_at = ? WHERE run_id = ?",
202
+ (_now_iso(), run_id),
203
+ )
204
+ conn.commit()
205
+ finally:
206
+ conn.close()
207
+
208
+ def insert_attempt(
209
+ self,
210
+ run_id: str,
211
+ task_id: str,
212
+ sample_index: int,
213
+ attempt_index: int,
214
+ prompt_messages_json: str,
215
+ reply: str,
216
+ actions_json: str,
217
+ passed: int,
218
+ error_detail: Optional[str] = None,
219
+ guard_violation: Optional[str] = None,
220
+ input_tokens: Optional[int] = None,
221
+ output_tokens: Optional[int] = None,
222
+ ) -> int:
223
+ conn = self._connect()
224
+ try:
225
+ cur = conn.execute(
226
+ "INSERT INTO collect_attempts (run_id, task_id, sample_index, "
227
+ "attempt_index, prompt_messages_json, reply, actions_json, passed, "
228
+ "error_detail, guard_violation, input_tokens, output_tokens, created_at) "
229
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
230
+ (run_id, task_id, sample_index, attempt_index, prompt_messages_json,
231
+ reply, actions_json, passed, error_detail, guard_violation,
232
+ input_tokens, output_tokens, _now_iso()),
233
+ )
234
+ conn.commit()
235
+ return cur.lastrowid
236
+ finally:
237
+ conn.close()
238
+
239
+ def insert_sft_placeholder(
240
+ self, run_id: str, task_id: str, prompt_messages_json: str
241
+ ) -> None:
242
+ """Queue a repair placeholder for a task no chain solved. INSERT OR
243
+ IGNORE plus UNIQUE(run_id, task_id) keeps it one row per task per run."""
244
+ conn = self._connect()
245
+ try:
246
+ conn.execute(
247
+ "INSERT OR IGNORE INTO sft_queue "
248
+ "(run_id, task_id, prompt_messages_json, created_at) "
249
+ "VALUES (?, ?, ?, ?)",
250
+ (run_id, task_id, prompt_messages_json, _now_iso()),
251
+ )
252
+ conn.commit()
253
+ finally:
254
+ conn.close()
255
+
256
+ # -- Ingest-server write path ---------------------------------------
257
+ # These methods back the collect ingest endpoint (_collect_server.py).
258
+ # They differ from the insert_* methods above in two ways: every write
259
+ # is idempotent (retries, flush re-runs, and flush-after-partial-delivery
260
+ # are no-ops), and the server stamps its own timestamps. Payload shapes
261
+ # mirror the column names 1:1 (RUN_FIELDS / ATTEMPT_*_FIELDS / SFT_FIELDS).
262
+
263
+ def _ensure_run(self, conn: sqlite3.Connection, run_id: str) -> None:
264
+ """Auto-create a recovered stub run when run_id has no row, so writes
265
+ referencing a run the server never saw are accepted, never rejected.
266
+ The stub is marked with the RECOVERED_SPEC sentinel."""
267
+ conn.execute(
268
+ "INSERT OR IGNORE INTO collect_runs (run_id, repo_path, spec, "
269
+ "tasks_file, model, endpoint, temperature, max_retries, k, started_at) "
270
+ "VALUES (?, '', ?, '', '', '', 0.0, 0, 0, ?)",
271
+ (run_id, RECOVERED_SPEC, _now_iso()),
272
+ )
273
+
274
+ def ingest_run(self, payload: dict) -> bool:
275
+ """Idempotent run-open: INSERT OR IGNORE on the run_id primary key,
276
+ started_at stamped by this server. Returns True when the row was
277
+ created, False when the run_id already existed (no changes made)."""
278
+ conn = self._connect()
279
+ try:
280
+ cur = conn.execute(
281
+ "INSERT OR IGNORE INTO collect_runs (run_id, repo_path, spec, "
282
+ "tasks_file, model, endpoint, temperature, max_retries, k, "
283
+ "started_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
284
+ tuple(payload[f] for f in RUN_FIELDS) + (_now_iso(),),
285
+ )
286
+ conn.commit()
287
+ return bool(cur.rowcount)
288
+ finally:
289
+ conn.close()
290
+
291
+ def ingest_attempts(self, rows: list) -> tuple:
292
+ """Idempotent batch attempt ingestion in one transaction: INSERT OR
293
+ IGNORE on the natural key (run_id, task_id, sample_index,
294
+ attempt_index), created_at stamped by this server, unknown run_ids
295
+ recovered via a stub run. Returns (inserted, duplicates)."""
296
+ inserted = duplicates = 0
297
+ conn = self._connect()
298
+ try:
299
+ for row in rows:
300
+ self._ensure_run(conn, row["run_id"])
301
+ cur = conn.execute(
302
+ "INSERT OR IGNORE INTO collect_attempts (run_id, task_id, "
303
+ "sample_index, attempt_index, prompt_messages_json, reply, "
304
+ "actions_json, passed, error_detail, guard_violation, "
305
+ "input_tokens, output_tokens, created_at) "
306
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
307
+ tuple(row[f] for f in ATTEMPT_REQUIRED_FIELDS)
308
+ + tuple(row.get(f) for f in ATTEMPT_OPTIONAL_FIELDS)
309
+ + (_now_iso(),),
310
+ )
311
+ if cur.rowcount:
312
+ inserted += 1
313
+ else:
314
+ duplicates += 1
315
+ conn.commit()
316
+ return inserted, duplicates
317
+ finally:
318
+ conn.close()
319
+
320
+ def finish_run_if_open(self, run_id: str) -> None:
321
+ """Replay-safe finish: stamps finished_at only while it is NULL, so a
322
+ flushed or retried finish marker never moves an existing stamp.
323
+ Unknown run_ids get a recovered stub run first."""
324
+ conn = self._connect()
325
+ try:
326
+ self._ensure_run(conn, run_id)
327
+ conn.execute(
328
+ "UPDATE collect_runs SET finished_at = ? "
329
+ "WHERE run_id = ? AND finished_at IS NULL",
330
+ (_now_iso(), run_id),
331
+ )
332
+ conn.commit()
333
+ finally:
334
+ conn.close()
335
+
336
+ def ingest_sft(self, payload: dict) -> bool:
337
+ """Idempotent sft placeholder ingestion (UNIQUE(run_id, task_id)),
338
+ created_at stamped by this server, unknown run_ids recovered.
339
+ Returns True when a row was created, False when already present."""
340
+ conn = self._connect()
341
+ try:
342
+ self._ensure_run(conn, payload["run_id"])
343
+ cur = conn.execute(
344
+ "INSERT OR IGNORE INTO sft_queue "
345
+ "(run_id, task_id, prompt_messages_json, created_at) "
346
+ "VALUES (?, ?, ?, ?)",
347
+ tuple(payload[f] for f in SFT_FIELDS) + (_now_iso(),),
348
+ )
349
+ conn.commit()
350
+ return bool(cur.rowcount)
351
+ finally:
352
+ conn.close()
353
+
354
+
355
+ def extract_pairs(store: CollectStore, run_id: Optional[str] = None) -> dict:
356
+ """Materialize eligible DPO preference pairs from collected attempts.
357
+
358
+ Pass/fail pairs join a passing attempt (chosen) with a failing attempt
359
+ (rejected) of the same task on byte-identical prompt_messages_json, both
360
+ free of the judge's env_failure flag, chosen_source 'local'. Green-vs-green
361
+ pairs exist only where the judge has ranked both passing attempts (lower
362
+ judge_rank wins), chosen_source 'judge-ranked'. sft_queue is never read.
363
+ Idempotent: UNIQUE(chosen_attempt_id, rejected_attempt_id) plus INSERT OR
364
+ IGNORE makes repeated runs, with or without a run filter, no-ops.
365
+
366
+ Returns {"created_local": n, "created_judge": n, "existing": n}."""
367
+ run_filter = ""
368
+ args: tuple = ()
369
+ if run_id is not None:
370
+ run_filter = " AND a.run_id = ? AND b.run_id = ?"
371
+ args = (run_id, run_id)
372
+
373
+ local_sql = (
374
+ "SELECT a.attempt_id, b.attempt_id, a.task_id "
375
+ "FROM collect_attempts a JOIN collect_attempts b "
376
+ "ON a.task_id = b.task_id "
377
+ "AND a.prompt_messages_json = b.prompt_messages_json "
378
+ "WHERE a.passed = 1 AND b.passed = 0 "
379
+ "AND a.env_failure = 0 AND b.env_failure = 0" + run_filter
380
+ )
381
+ judge_sql = (
382
+ "SELECT a.attempt_id, b.attempt_id, a.task_id "
383
+ "FROM collect_attempts a JOIN collect_attempts b "
384
+ "ON a.task_id = b.task_id "
385
+ "AND a.prompt_messages_json = b.prompt_messages_json "
386
+ "WHERE a.passed = 1 AND b.passed = 1 "
387
+ "AND a.judge_rank IS NOT NULL AND b.judge_rank IS NOT NULL "
388
+ "AND a.judge_rank < b.judge_rank "
389
+ "AND a.env_failure = 0 AND b.env_failure = 0" + run_filter
390
+ )
391
+
392
+ counts = {"created_local": 0, "created_judge": 0, "existing": 0}
393
+ conn = store._connect()
394
+ try:
395
+ for sql, source, key in (
396
+ (local_sql, "local", "created_local"),
397
+ (judge_sql, "judge-ranked", "created_judge"),
398
+ ):
399
+ for chosen_id, rejected_id, task_id in conn.execute(sql, args).fetchall():
400
+ cur = conn.execute(
401
+ "INSERT OR IGNORE INTO dpo_pairs (chosen_attempt_id, "
402
+ "rejected_attempt_id, task_id, chosen_source, status, created_at) "
403
+ "VALUES (?, ?, ?, ?, 'candidate', ?)",
404
+ (chosen_id, rejected_id, task_id, source, _now_iso()),
405
+ )
406
+ if cur.rowcount:
407
+ counts[key] += 1
408
+ else:
409
+ counts["existing"] += 1
410
+ conn.commit()
411
+ finally:
412
+ conn.close()
413
+ return counts
414
+
415
+
416
+ def export_pairs(
417
+ store: CollectStore, out_path: Path, statuses: Optional[list] = None
418
+ ) -> int:
419
+ """Write pairs as dpo-jsonl (contracts/export-format.md): one JSON object
420
+ per line with exactly the keys prompt (the pair's shared message list),
421
+ chosen, and rejected. Returns the number of lines written; no matching
422
+ pairs writes an empty file."""
423
+ sql = (
424
+ "SELECT c.prompt_messages_json AS prompt, c.reply AS chosen, "
425
+ "r.reply AS rejected "
426
+ "FROM dpo_pairs p "
427
+ "JOIN collect_attempts c ON c.attempt_id = p.chosen_attempt_id "
428
+ "JOIN collect_attempts r ON r.attempt_id = p.rejected_attempt_id"
429
+ )
430
+ args: tuple = ()
431
+ if statuses:
432
+ sql += f" WHERE p.status IN ({', '.join('?' * len(statuses))})"
433
+ args = tuple(statuses)
434
+ sql += " ORDER BY p.pair_id"
435
+
436
+ conn = store._connect()
437
+ try:
438
+ rows = conn.execute(sql, args).fetchall()
439
+ finally:
440
+ conn.close()
441
+
442
+ out_path = Path(out_path)
443
+ out_path.parent.mkdir(parents=True, exist_ok=True)
444
+ with out_path.open("w", encoding="utf-8") as f:
445
+ for row in rows:
446
+ f.write(json.dumps(
447
+ {
448
+ "prompt": json.loads(row["prompt"]),
449
+ "chosen": row["chosen"],
450
+ "rejected": row["rejected"],
451
+ },
452
+ ensure_ascii=False,
453
+ ) + "\n")
454
+ return len(rows)
455
+
456
+
457
+ def stats(store: CollectStore, run_id: Optional[str] = None) -> list:
458
+ """Per-run collection health: attempt count, per-task pass rates with the
459
+ 20 to 80 percent band flag, pair yield (pairs whose chosen attempt belongs
460
+ to the run), and repair-queue depth (needs_solution rows for the run)."""
461
+ conn = store._connect()
462
+ try:
463
+ if run_id is not None:
464
+ runs = conn.execute(
465
+ "SELECT run_id, started_at FROM collect_runs WHERE run_id = ? "
466
+ "ORDER BY started_at", (run_id,),
467
+ ).fetchall()
468
+ else:
469
+ runs = conn.execute(
470
+ "SELECT run_id, started_at FROM collect_runs ORDER BY started_at"
471
+ ).fetchall()
472
+
473
+ out = []
474
+ for run in runs:
475
+ rid = run["run_id"]
476
+ task_rows = conn.execute(
477
+ "SELECT task_id, COUNT(*) AS attempts, SUM(passed) AS passes "
478
+ "FROM collect_attempts WHERE run_id = ? GROUP BY task_id "
479
+ "ORDER BY task_id", (rid,),
480
+ ).fetchall()
481
+ tasks = []
482
+ for t in task_rows:
483
+ rate = (t["passes"] or 0) / t["attempts"]
484
+ tasks.append({
485
+ "task_id": t["task_id"],
486
+ "attempts": t["attempts"],
487
+ "passes": t["passes"] or 0,
488
+ "pass_rate": rate,
489
+ "in_band": 0.2 <= rate <= 0.8,
490
+ })
491
+ pair_yield = conn.execute(
492
+ "SELECT COUNT(*) FROM dpo_pairs p JOIN collect_attempts a "
493
+ "ON a.attempt_id = p.chosen_attempt_id WHERE a.run_id = ?", (rid,),
494
+ ).fetchone()[0]
495
+ repair_depth = conn.execute(
496
+ "SELECT COUNT(*) FROM sft_queue WHERE run_id = ? "
497
+ "AND status = 'needs_solution'", (rid,),
498
+ ).fetchone()[0]
499
+ out.append({
500
+ "run_id": rid,
501
+ "started_at": run["started_at"],
502
+ "attempts": sum(t["attempts"] for t in tasks),
503
+ "tasks": tasks,
504
+ "pair_yield": pair_yield,
505
+ "repair_depth": repair_depth,
506
+ })
507
+ return out
508
+ finally:
509
+ conn.close()