Skip to content

Commit ceb36b8

Browse files
tzhouamclaude
andcommitted
Add Tier 1 lints, per-workflow ledgers and the weekly improvement cycle (dry-run)
Meta-improvement engine P1 (design §4, §5, §9). The cycle reads trace/1, never a model; it writes only its own ledger directory and its own trace records. Proposals stay in the ledger: nothing reaches GitHub until P4. improve/reader.py groups records into units of work by context.unit_id (records without one, the knowledge service's and the review bot's, fall back to run_id:step so nothing in the store is invisible) and reads a window by JSONL day file. improve/lints.py is the Tier 1 catalogue: 17 deterministic lints, each with a version and the campaign finding it comes from (L01 truncation, L02 budget exhaustion, L03 empty final after tools, L04 contract reject, L05 parse-failure rate vs baseline, L06 duplicate rendering, L07 silent fallback / served-model mismatch, L08 zero-yield pass, L09 cap drops, L10 unresolved anchors, L11 cost/latency robust-z outliers, L12 refusals and out-of-scope edits, L13 provider errors which quarantine the unit, L14 trace/1-native completeness, L15 repeated identical tool calls, L16 evidence-cap drops, L17 epistemic claims without a backing tool call). A lint whose evidence shape a producer does not emit yet finds nothing. The robust z floors the MAD at a tenth of the median so a uniform history does not flag every change as infinite. improve/ledger.py keeps one JSON ledger per workflow: per-cycle stats and lint rates, rolling baseline samples, the proposal state machine (open -> experiment-registered -> supported|neutral|refuted|underpowered -> landed|closed, 30 days untouched -> stale), one live proposal per lint or stage, holds, channel liveness; every change is also a decision record. improve/cycle.py runs a cycle: preflight (improve_enabled is the kill switch; holds are reported), lints and baselines per workflow, Tier 1 proposals when a lint's unit rate worsened 1.5x with at least 3 units, the stale sweep, a Markdown + JSON report and a decision(type=cycle) record, then the cursor advances. The engine's own records are units of the next cycle (self-enrolment). is_due/maybe_run_weekly implement the weekly slot (Monday 05:00 UTC by default); the kb serve scheduler calls it every tick under its lease, isolated like its other tick items. workflow_improve is a new read-only L2 task kind with the vetted workflow-improve playbook (improve.preflight -> improve.lint -> improve.ledger -> report.final_summary); the intent and chat enums list it. Settings: improve_enabled, improve_cycle_weekday/hour, improve_ledger_dir, improve_budget_usd_week and improve_budget_judge_calls_week (enforced in P3). CLI: improve cycle, improve ledger, improve lints. Review round 1 (gpt-6.1-sol-high) fixes: units are attributed whole to the cycle in which they END (records read from a two-day lookback before the window; a unit still in flight at the cutoff is deferred untouched), so a lint that needs both halves of a unit still fires; every ledger workflow is swept and reported each cycle, including ones with no unit in the window, so a workflow that stopped producing records still goes stale and reports a dead channel; a cross-process file lock (trace_store.file_lock, the same primitive as the migration lock) serializes whole cycles per ledger directory including the due check, and every ledger read-modify-write takes the workflow's lock with a per-process temp name. Review round 2 fixes: a unit counts only with completion evidence (a terminal decision after its last call, or an hour of silence before the cutoff) and only once — the cursor keeps the counted ids for the lookback span, so a unit still running at a cutoff is deferred and never counted twice; the scheduler, the CLI and the playbook share one default ledger directory (cycle.DEFAULT_LEDGER_DIR / ledger_dir_for); L11 prices a unit with the same settings the baseline used; the unresolved-anchor pattern matches `file.py:?` at a whitespace or text boundary; the CLI entry block sits after the helpers it calls; the reader defers an unterminated trailing JSONL line and skips a torn one instead of aborting the cycle. Review round 3 fixes: a unit that finished before the window counts only if it was still deferred (incomplete) at the window's start, so the lookback never pulls in earlier completed work (the first cycle reports seven days, an explicit --since window means what it says); L07 compares served and requested models through canonical_model with the configured aliases, exactly as the served-model guard does. Review round 4 fix: the cursor persists the ACTUAL deferred set and the engine's origin instead of re-deriving completion at the window start from timestamps (a record can become visible after the cutoff: a torn trailing line); a unit that finished before the window counts when it was deferred last time or when it became visible inside the engine's contiguous coverage (never counted before), and is historical otherwise; regression test with a torn decision line across two cycles. The coverage origin resets to the window start whenever continuity breaks (an explicit window elsewhere), so work skipped between windows never leaks into a later contiguous cycle. A cursor written before the deferred set existed recovers the old cycle's pending units from their own records at the old cutoff (a unit complete then was counted then and is never replayed), once, on upgrade. Tests: test_improve_p1.py — reader grouping and fallbacks, a clean unit has no findings, every lint fires on its fixture, baseline-dependent lints, completeness, quarantine, the ledger state machine and stale sweep, two cycles end to end (cursor, proposal opened on worsening, quarantined unit out of the baseline, cycle records), kill switch/holds/slot, the scheduler hook, kind/playbook/planner resolution, the playbook through the executor, the CLI. Full suite green except test_mcp.py (host mcp 2.x, fails on main). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 40bf86f commit ceb36b8

15 files changed

Lines changed: 2094 additions & 5 deletions

File tree

‎doc/architecture/SPEC/improve.md‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# improve/ —— 规范(元改进引擎)
2+
3+
<!-- verified-against: 2026-09-30 -->
4+
5+
`设计:/data/zhoutaichang/copilot/meta-improvement-engine-design.md v1(GPT-6 sol 批准 2026-09-29) · refactor-status: building (P0–P1 已落地)`
6+
7+
## 职责
8+
面向任意 trace/1 工作流的"取证—实验—提案"循环。**只提案**:引擎的写权限限于追加自己的 trace/1 记录、
9+
写自己的账本目录、在影子环境跑实验;产线变更一律走 PR。
10+
11+
## 模块
12+
| 文件 | 阶段 | 作用 |
13+
|---|---|---|
14+
| `enroll.py` | P0 | 工作流声明(`workflows/*.yaml` + `IMPROVE_WORKFLOWS_DIRS`):kind/unit/item_key/fingerprint.covers/capture/shadow_tools;未声明 = Tier 1 |
15+
| `fingerprint.py` | P0 | 声明式配置指纹(playbook sha、settings、prompt 文件哈希、routing、`resolved_models`、tools、knowledge_snapshot、copilot_sha);覆盖项缺失 → 无指纹、记 `fingerprint_missing` |
16+
| `shadow.py` | P0 | 影子四层边界:`harden_scope`(严格 extra、读围栏)、`shadow_env`/`make_executables_dir`/`assert_boundaries`(凭据与可执行白名单)、`make_shadow_clone`(`--shared` 独立克隆、无 remote、钉住的 base ref)、`smoke` |
17+
| `staging.py` | P0 | 隔离前的条目快照:`stage_item` 复用 `pr.fetch_diff` 的钉住取数,写 `item_staged` 记录;任一必需输入不可得即拒绝 |
18+
| `reader.py` | P1 | trace/1 → 工作单元(按 `context.unit_id`,RB/知识服务记录回退到 `run_id:step`);窗口按 JSONL 日期文件读取 |
19+
| `lints.py` | P1 | Tier 1 的 17 条确定性 lint(L01–L17,每条带版本与出处);`Baseline` 稳健 z(MAD 下限 = 中位数的 10%);L13 隔离 |
20+
| `ledger.py` | P1 | 每工作流 JSON 账本:周期统计、基线样本、提案状态机(open → experiment-registered → supported/neutral/refuted/underpowered → landed/closed;30 天无人触碰 → stale)、hold、通道活性;每次变更同时写 `decision` |
21+
| `cycle.py` | P1 | 周期:preflight(`improve_enabled` 是 kill switch)→ lints + 基线 → 账本 + Tier 1 提案(lint 恶化 ≥1.5× 且 ≥3 单元)→ stale 清扫 → 报告(`reports/cycle-*.md/json`)+ `decision(type=cycle)`;`is_due`/`maybe_run_weekly` 是周度槽位 |
22+
| `cli.py` | P0/P1 | `improve migrate-index|rebuild-index|rollback-index|compare-index|verify-index|cycle|ledger|lints` |
23+
24+
## 接入点
25+
- 执行器:`settings.trace_store_root` 非空时绑定 store 并为每步绑定单元上下文;`settings.improve_shadow` 下拒绝非 read/report 步骤。
26+
- `tools.dispatch` / `LLM.create` / `HarnessLLM.create` / `run_harness_step` / MCP bridge:全保真采集。
27+
- `kb serve` 调度器:每 tick 调 `_improve_cycle()`,周度槽位到达即跑一次周期(与知识服务同一租约、异常隔离)。
28+
- 任务种类 `workflow_improve`(L2,READ_ONLY_KINDS);playbook `workflow-improve`(preflight → lint → ledger → report)。
29+
30+
## 不变量
31+
- P1 周期永远 dry-run:提案只在账本与 trace 里,不上 GitHub(P4)。
32+
- 引擎自身的记录(`playbook=workflow-improve`)是下一周期的 Tier 1 单元(自登记)。
33+
- lint 只读 trace,不调模型;无证据形态的 lint 不猜。
34+
35+
## 测试
36+
`test_improve_p0.py`、`test_improve_p0b.py`、`test_improve_p1.py`。

‎playbooks/workflow-improve.yaml‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# The meta-improvement engine's weekly cycle (design §4). A vetted reuse
2+
# playbook, never generated. Every step is read-only toward the target
3+
# repositories and the trace store (the engine only appends its own records);
4+
# publication (P4) is a separate `risk: push` step behind the post gate.
5+
name: workflow-improve
6+
version: 1
7+
status: active
8+
provenance:
9+
created_by: human
10+
source: meta-improvement-engine-design.md v1 (sol-approved 2026-09-29), P1 slice
11+
task_kinds: [workflow_improve]
12+
repos: []
13+
params: {}
14+
steps:
15+
- {id: preflight, step: improve.preflight}
16+
- {id: lint, step: improve.lint}
17+
- {id: ledger, step: improve.ledger}
18+
- {id: report, step: report.final_summary}
19+
success: one cycle recorded (decision type=cycle), the per-workflow ledgers
20+
updated, the cycle report written; proposals stay in the ledger until P4

‎src/infermatrix_copilot/chat.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
4444
You can: chat and answer questions; inspect past runs, logs and reports; read/search \
4545
the repository; and execute maintenance tasks via the run_task tool. Task kinds: \
46-
repo_rebase, pr_rebase, pr_debug, pr_review, issue_answer, issue_filter, repo_profile.
46+
repo_rebase, pr_rebase, pr_debug, pr_review, issue_answer, issue_filter, repo_profile, workflow_improve.
4747
4848
Rules:
4949
- To execute anything, CALL a tool — never claim work happened without a tool result.
@@ -67,7 +67,7 @@
6767
"kind": {"type": "string",
6868
"enum": ["repo_rebase", "pr_rebase", "pr_debug",
6969
"pr_review", "issue_answer", "issue_filter",
70-
"repo_profile"]},
70+
"repo_profile", "workflow_improve"]},
7171
"pr": {"type": "integer"},
7272
"issue": {"type": "integer"},
7373
"repo": {"type": "string",

‎src/infermatrix_copilot/config.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,17 @@ def _parse_mixture(cls, v):
558558
# Extra directories of workflow declarations (os.pathsep separated),
559559
# consulted after the package's builtin improve/workflows/.
560560
improve_workflows_dirs: str = ""
561+
# The weekly cycle (design §4/§10). improve_enabled is THE kill switch;
562+
# the slot is `improve_cycle_weekday` (0=Monday) at `improve_cycle_hour`
563+
# UTC, after the 04:00 PR-state sweep; the ledgers and reports live under
564+
# improve_ledger_dir (default ~/.infermatrix-copilot/improve).
565+
improve_enabled: bool = False
566+
improve_cycle_weekday: int = 0
567+
improve_cycle_hour: int = 5
568+
improve_ledger_dir: str = ""
569+
# the hard weekly envelope (P3 enforces it per model call)
570+
improve_budget_usd_week: float = 20.0
571+
improve_budget_judge_calls_week: int = 300
561572

562573
# Metrics (eval/METRICS_RESEARCH.md) — per-run metrics.json: CATQ = Q·S/C.
563574
# Reference budgets are EXPLICIT deployment assumptions (RQS3e precedent):

‎src/infermatrix_copilot/engine/steps/improve.py‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,86 @@ async def _stage_items(ctx: StepContext) -> StepResult:
4545
ctx.state.update(updates)
4646
return StepResult(True, summary=f"staged {len(snapshots)} item(s) under {shadow_root}",
4747
outputs={"staged": sorted(snapshots), "state_updates": updates})
48+
49+
50+
def _ledger_dir(ctx: StepContext) -> Path:
51+
from ...improve.cycle import ledger_dir_for
52+
53+
override = str(ctx.params.get("ledger_dir") or "")
54+
return Path(override).expanduser() if override else ledger_dir_for(ctx.settings)
55+
56+
57+
def _trace_store(ctx: StepContext):
58+
from ...trace_store import TraceStore, current_store
59+
60+
store = current_store()
61+
if store is not None:
62+
return store
63+
root = str(getattr(ctx.settings, "trace_store_root", "") or "")
64+
if not root:
65+
return None
66+
return TraceStore(Path(root).expanduser())
67+
68+
69+
@step("improve.preflight", "deterministic", "read",
70+
"Kill switch + per-workflow holds; refuses the cycle loudly.")
71+
async def _preflight(ctx: StepContext) -> StepResult:
72+
from ...improve.cycle import CycleRefused, preflight
73+
from ...improve.ledger import Ledger
74+
75+
store = _trace_store(ctx)
76+
if store is None:
77+
return StepResult(False, FailureKind.BLOCKED, "no trace store (set TRACE_STORE_ROOT)")
78+
try:
79+
gate = preflight(ctx.settings, Ledger(_ledger_dir(ctx), store))
80+
except CycleRefused as exc:
81+
return StepResult(False, FailureKind.BLOCKED, str(exc))
82+
updates = {"improve_holds": gate["holds"], "improve_ledger_dir": str(_ledger_dir(ctx))}
83+
ctx.state.update(updates)
84+
return StepResult(True, summary=f"preflight ok; holds: {gate['holds'] or 'none'}",
85+
outputs={"state_updates": updates})
86+
87+
88+
@step("improve.lint", "deterministic", "read",
89+
"Tier 1 lints + baselines + ledger update over the cycle window (dry-run).")
90+
async def _lint(ctx: StepContext) -> StepResult:
91+
"""Runs the whole P1 cycle body (lints, baselines, Tier 1 proposals in
92+
the ledger, stale sweep, report). Window from params `since`/`until`
93+
(epoch seconds) or the cursor. Publishes `improve_report_path`."""
94+
from ...improve.cycle import CycleRefused, run_cycle
95+
96+
store = _trace_store(ctx)
97+
if store is None:
98+
return StepResult(False, FailureKind.BLOCKED, "no trace store (set TRACE_STORE_ROOT)")
99+
since = ctx.params.get("since")
100+
until = ctx.params.get("until")
101+
try:
102+
report = run_cycle(store, ctx.settings, _ledger_dir(ctx),
103+
since=float(since) if since else None, until=float(until) if until else None,
104+
dry_run=True, force=bool(ctx.params.get("force")))
105+
except CycleRefused as exc:
106+
return StepResult(False, FailureKind.BLOCKED, str(exc))
107+
updates = {"improve_cycle": {k: report[k] for k in ("at", "since", "until", "units", "proposals_opened")},
108+
"improve_report_dir": str(_ledger_dir(ctx) / "reports")}
109+
ctx.state.update(updates)
110+
opened = len(report["proposals_opened"])
111+
return StepResult(True, summary=f"cycle over {report['units']} units in {len(report['workflows'])} workflow(s); "
112+
f"{opened} Tier 1 proposal(s) opened (dry-run)",
113+
outputs={"report": report, "state_updates": updates})
114+
115+
116+
@step("improve.ledger", "deterministic", "read",
117+
"Summarize the per-workflow ledgers for the run report.")
118+
async def _ledger_summary(ctx: StepContext) -> StepResult:
119+
from ...improve.ledger import Ledger
120+
121+
ledger = Ledger(_ledger_dir(ctx))
122+
lines = []
123+
for workflow in ledger.workflows():
124+
wl = ledger.load(workflow)
125+
open_ = [p for p in wl.proposals if p.state not in ("landed", "closed")]
126+
last = wl.cycles[-1] if wl.cycles else {}
127+
lines.append(f"{workflow}: tier {wl.tier}, {len(wl.cycles)} cycle(s), last units {last.get('units', 0)}, "
128+
f"{len(open_)} live proposal(s)" + (f", HOLD: {wl.hold}" if wl.hold else ""))
129+
summary = "\n".join(lines) or "no workflows in the ledger yet"
130+
return StepResult(True, summary=summary[:400], outputs={"ledger_summary": summary})

‎src/infermatrix_copilot/improve/cli.py‎

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
improve compare-index read-only: rebuild a throwaway copy and diff it
1313
against the live index (safe with live writers)
1414
improve verify-index read-only: id sets and schema-2 columns vs the records
15+
improve cycle [--since EPOCH] [--until EPOCH] [--force] [--ledger-dir DIR]
16+
run one weekly cycle now (Tier 1 lints, baselines,
17+
ledger, report; dry-run — proposals stay local)
18+
improve ledger [--workflow W] [--ledger-dir DIR] show the ledgers
19+
improve lints the Tier 1 lint catalogue (ids, versions, origins)
1520
1621
The writer-quiescence gate (design §7.1). Pause flags alone do not stop an
1722
intake or sweep that is already running, so the gate requires evidence that
@@ -94,7 +99,8 @@ def main(argv: list[str] | None = None) -> int:
9499
parser = argparse.ArgumentParser(prog="infermatrix-copilot improve",
95100
description="operate the meta-improvement engine's trace index")
96101
sub = parser.add_subparsers(dest="command", required=True)
97-
for name in ("migrate-index", "rebuild-index", "rollback-index", "compare-index", "verify-index"):
102+
for name in ("migrate-index", "rebuild-index", "rollback-index", "compare-index", "verify-index",
103+
"cycle", "ledger", "lints"):
98104
p = sub.add_parser(name)
99105
p.add_argument("--trace-root", default=None, help="trace store root (TRACE_STORE_ROOT / KB_STATE_DIR/traces)")
100106
if name in ("migrate-index", "rebuild-index", "rollback-index"):
@@ -104,7 +110,22 @@ def main(argv: list[str] | None = None) -> int:
104110
p.add_argument("--no-backup", action="store_true")
105111
if name == "rebuild-index":
106112
p.add_argument("--to-schema", type=int, default=None, help="0 (pin downgrade) or the current schema")
113+
if name in ("cycle", "ledger"):
114+
p.add_argument("--ledger-dir", default=None)
115+
if name == "cycle":
116+
p.add_argument("--since", type=float, default=None)
117+
p.add_argument("--until", type=float, default=None)
118+
p.add_argument("--force", action="store_true", help="run even when improve_enabled is off (operator)")
119+
if name == "ledger":
120+
p.add_argument("--workflow", default=None)
107121
args = parser.parse_args(argv)
122+
if args.command == "lints":
123+
from .lints import catalogue
124+
125+
print(json.dumps(catalogue(), ensure_ascii=False, indent=1))
126+
return 0
127+
if args.command in ("cycle", "ledger"):
128+
return _cycle_commands(args)
108129
store = _store(args)
109130
try:
110131
if args.command == "migrate-index":
@@ -129,5 +150,43 @@ def main(argv: list[str] | None = None) -> int:
129150
return 0 if report.get("ok", True) else 1
130151

131152

153+
154+
def _ledger_dir(args) -> Path:
155+
from ..config import Settings
156+
from .cycle import ledger_dir_for
157+
158+
return Path(args.ledger_dir).expanduser() if args.ledger_dir else ledger_dir_for(Settings())
159+
160+
161+
def _cycle_commands(args) -> int:
162+
from ..config import Settings
163+
from .cycle import CycleRefused, run_cycle
164+
from .ledger import Ledger
165+
166+
ledger_dir = _ledger_dir(args)
167+
if args.command == "ledger":
168+
ledger = Ledger(ledger_dir)
169+
names = [args.workflow] if args.workflow else ledger.workflows()
170+
out = {}
171+
for name in names:
172+
wl = ledger.load(name)
173+
out[name] = {"tier": wl.tier, "declared": wl.declared, "hold": wl.hold, "cycles": wl.cycles[-4:],
174+
"proposals": [{"id": p.id, "state": p.state, "lint": p.lint, "stage": p.stage,
175+
"claim": p.claim, "issue": p.issue} for p in wl.proposals]}
176+
print(json.dumps(out, ensure_ascii=False, indent=1, default=str))
177+
return 0
178+
store = _store(args)
179+
try:
180+
report = run_cycle(store, Settings(), ledger_dir, since=args.since, until=args.until,
181+
dry_run=True, force=args.force)
182+
except CycleRefused as exc:
183+
print(json.dumps({"refused": str(exc)}), file=sys.stderr)
184+
return 1
185+
print(json.dumps({k: report[k] for k in ("at", "since", "until", "units", "proposals_opened", "seconds")},
186+
ensure_ascii=False, indent=1, default=str))
187+
print(f"report: {ledger_dir / 'reports'}", file=sys.stderr)
188+
return 0
189+
190+
132191
if __name__ == "__main__": # pragma: no cover
133192
sys.exit(main())

0 commit comments

Comments
 (0)