pushback 0.1.2__tar.gz → 0.1.3__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 (23) hide show
  1. {pushback-0.1.2/src/pushback.egg-info → pushback-0.1.3}/PKG-INFO +19 -7
  2. {pushback-0.1.2 → pushback-0.1.3}/README.md +18 -6
  3. {pushback-0.1.2 → pushback-0.1.3}/pyproject.toml +1 -1
  4. pushback-0.1.3/src/pushback/__init__.py +1 -0
  5. {pushback-0.1.2 → pushback-0.1.3}/src/pushback/cli.py +24 -9
  6. {pushback-0.1.2 → pushback-0.1.3}/src/pushback/extract.py +1 -0
  7. {pushback-0.1.2 → pushback-0.1.3}/src/pushback/rules.py +215 -154
  8. {pushback-0.1.2 → pushback-0.1.3/src/pushback.egg-info}/PKG-INFO +19 -7
  9. {pushback-0.1.2 → pushback-0.1.3}/tests/test_pushback.py +143 -70
  10. pushback-0.1.2/src/pushback/__init__.py +0 -1
  11. {pushback-0.1.2 → pushback-0.1.3}/LICENSE +0 -0
  12. {pushback-0.1.2 → pushback-0.1.3}/setup.cfg +0 -0
  13. {pushback-0.1.2 → pushback-0.1.3}/src/pushback/audit.py +0 -0
  14. {pushback-0.1.2 → pushback-0.1.3}/src/pushback/export.py +0 -0
  15. {pushback-0.1.2 → pushback-0.1.3}/src/pushback/label.py +0 -0
  16. {pushback-0.1.2 → pushback-0.1.3}/src/pushback/prompt.py +0 -0
  17. {pushback-0.1.2 → pushback-0.1.3}/src/pushback/report.py +0 -0
  18. {pushback-0.1.2 → pushback-0.1.3}/src/pushback/stats.py +0 -0
  19. {pushback-0.1.2 → pushback-0.1.3}/src/pushback.egg-info/SOURCES.txt +0 -0
  20. {pushback-0.1.2 → pushback-0.1.3}/src/pushback.egg-info/dependency_links.txt +0 -0
  21. {pushback-0.1.2 → pushback-0.1.3}/src/pushback.egg-info/entry_points.txt +0 -0
  22. {pushback-0.1.2 → pushback-0.1.3}/src/pushback.egg-info/requires.txt +0 -0
  23. {pushback-0.1.2 → pushback-0.1.3}/src/pushback.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pushback
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: How often do you correct your coding agent, and at what kind of work? Measured from your own Claude Code transcripts.
5
5
  Author: Intikhab Azam
6
6
  License: MIT
@@ -54,7 +54,7 @@ shows what you use your AI coding agent for, how often you correct it on each
54
54
  kind of work (code, writing, media, research, ops, meta), and how often a
55
55
  correction needs correcting again. It drafts
56
56
  **CLAUDE.md rules** from the corrections you keep repeating, shows which of
57
- your existing rules keep getting broken, and exports your corrections as
57
+ your existing rules those corrections run into, and exports your corrections as
58
58
  **preference pairs** (prompt / chosen / rejected). A hand-audit step tells you
59
59
  how far to trust every number.
60
60
 
@@ -67,7 +67,7 @@ pip install pushback
67
67
  The author's own 1,633 messages to Claude Code:
68
68
 
69
69
  <p align="center">
70
- <img src="assets/rates.svg" width="100%" alt="Correction rate by task: media 43.0%, writing 24.7%, research 6.9%, code 5.9%, meta 4.7%, ops 4.5%. One in three corrections needed a second correction. Three existing rules kept getting broken.">
70
+ <img src="assets/rates.svg" width="100%" alt="Correction rate by task: media 43.0%, writing 24.7%, research 6.9%, code 5.9%, meta 4.7%, ops 4.5%. One in three corrections needed a second correction. One existing rule kept getting broken after it was written.">
71
71
  </p>
72
72
 
73
73
  | task | messages | share of use | corrections | rate | 95% CI | corrected again |
@@ -94,7 +94,7 @@ Most corrected topics (at least 5 messages each):
94
94
 
95
95
  - Against 91 hand-checked messages the correction labels had **precision 0.97 and recall 0.80**. Topic labels were spot-checked, not audited.
96
96
  - **1 in 3 corrections needed a second one.** 70 of 201 fixes got corrected again (35%), 41% on writing and 44% on images.
97
- - **3 rules the author had already written kept getting broken**, with 6 to 8 corrections each.
97
+ - **1 rule the author had already written kept getting broken**: "images must not look AI-generated" was corrected 3 more times after it was written. Two other matching rules turned out to be written *because of* the corrections, which is why every rule now shows its evidence dates.
98
98
 
99
99
  > [!NOTE]
100
100
  > These rates describe a workflow, not a model. The author's code work runs
@@ -177,17 +177,29 @@ pushback rules # drafts rules into pushback-dat
177
177
  pushback rules --existing CLAUDE.md notes/*.md # also check against the rules you already have
178
178
  pushback rules --prompt-file # no API key? writes the request to a file instead
179
179
  pushback rules --from-response reply.json # ...and reads Claude's answer back
180
+ pushback rules --runs 3 # ask 3 times, keep only the rules that keep coming back
180
181
  ```
181
182
 
182
183
  The model groups corrections that share a cause and drafts one CLAUDE.md
183
184
  instruction per group. Support is counted from the correction ids it cites,
184
185
  not from its own numbers, and a rule needs at least 3 real corrections.
185
186
 
187
+ **Stable rules.** A model won't write the same rules twice. With `--runs 3`
188
+ (or three replies passed to `--from-response`), pushback matches rules across
189
+ runs by the corrections they cite, not by their wording, and keeps only the
190
+ ones that come back in at least 2 of 3 runs. Each rule shows how many runs it
191
+ appeared in, and the unstable ones are listed separately instead of vanishing.
192
+
193
+ On the author's 214 corrections, three independent runs wrote 12, 10 and 7
194
+ rules: 16 distinct rules in total. 5 came back in all three runs, 3 in two,
195
+ and **8 showed up only once**. A single run mixes real rules with one-offs.
196
+
186
197
  When you pass your existing rule files, the output splits in two:
187
198
 
188
- - **Rules you already have that keep getting broken.** These are the useful
189
- ones. A rule that exists and still gets corrected isn't working: it's too
190
- vague, or the agent doesn't read it at the right moment.
199
+ - **Rules you already have that these corrections hit.** If the corrections
200
+ came after you wrote the rule, the rule isn't working: it's too vague, or the
201
+ agent doesn't read it at the right moment. pushback can't know when you wrote
202
+ a rule, so check the evidence dates.
191
203
  - **New rules to consider.** Recurring corrections with no rule behind them.
192
204
 
193
205
  > [!TIP]
@@ -27,7 +27,7 @@ shows what you use your AI coding agent for, how often you correct it on each
27
27
  kind of work (code, writing, media, research, ops, meta), and how often a
28
28
  correction needs correcting again. It drafts
29
29
  **CLAUDE.md rules** from the corrections you keep repeating, shows which of
30
- your existing rules keep getting broken, and exports your corrections as
30
+ your existing rules those corrections run into, and exports your corrections as
31
31
  **preference pairs** (prompt / chosen / rejected). A hand-audit step tells you
32
32
  how far to trust every number.
33
33
 
@@ -40,7 +40,7 @@ pip install pushback
40
40
  The author's own 1,633 messages to Claude Code:
41
41
 
42
42
  <p align="center">
43
- <img src="assets/rates.svg" width="100%" alt="Correction rate by task: media 43.0%, writing 24.7%, research 6.9%, code 5.9%, meta 4.7%, ops 4.5%. One in three corrections needed a second correction. Three existing rules kept getting broken.">
43
+ <img src="assets/rates.svg" width="100%" alt="Correction rate by task: media 43.0%, writing 24.7%, research 6.9%, code 5.9%, meta 4.7%, ops 4.5%. One in three corrections needed a second correction. One existing rule kept getting broken after it was written.">
44
44
  </p>
45
45
 
46
46
  | task | messages | share of use | corrections | rate | 95% CI | corrected again |
@@ -67,7 +67,7 @@ Most corrected topics (at least 5 messages each):
67
67
 
68
68
  - Against 91 hand-checked messages the correction labels had **precision 0.97 and recall 0.80**. Topic labels were spot-checked, not audited.
69
69
  - **1 in 3 corrections needed a second one.** 70 of 201 fixes got corrected again (35%), 41% on writing and 44% on images.
70
- - **3 rules the author had already written kept getting broken**, with 6 to 8 corrections each.
70
+ - **1 rule the author had already written kept getting broken**: "images must not look AI-generated" was corrected 3 more times after it was written. Two other matching rules turned out to be written *because of* the corrections, which is why every rule now shows its evidence dates.
71
71
 
72
72
  > [!NOTE]
73
73
  > These rates describe a workflow, not a model. The author's code work runs
@@ -150,17 +150,29 @@ pushback rules # drafts rules into pushback-dat
150
150
  pushback rules --existing CLAUDE.md notes/*.md # also check against the rules you already have
151
151
  pushback rules --prompt-file # no API key? writes the request to a file instead
152
152
  pushback rules --from-response reply.json # ...and reads Claude's answer back
153
+ pushback rules --runs 3 # ask 3 times, keep only the rules that keep coming back
153
154
  ```
154
155
 
155
156
  The model groups corrections that share a cause and drafts one CLAUDE.md
156
157
  instruction per group. Support is counted from the correction ids it cites,
157
158
  not from its own numbers, and a rule needs at least 3 real corrections.
158
159
 
160
+ **Stable rules.** A model won't write the same rules twice. With `--runs 3`
161
+ (or three replies passed to `--from-response`), pushback matches rules across
162
+ runs by the corrections they cite, not by their wording, and keeps only the
163
+ ones that come back in at least 2 of 3 runs. Each rule shows how many runs it
164
+ appeared in, and the unstable ones are listed separately instead of vanishing.
165
+
166
+ On the author's 214 corrections, three independent runs wrote 12, 10 and 7
167
+ rules: 16 distinct rules in total. 5 came back in all three runs, 3 in two,
168
+ and **8 showed up only once**. A single run mixes real rules with one-offs.
169
+
159
170
  When you pass your existing rule files, the output splits in two:
160
171
 
161
- - **Rules you already have that keep getting broken.** These are the useful
162
- ones. A rule that exists and still gets corrected isn't working: it's too
163
- vague, or the agent doesn't read it at the right moment.
172
+ - **Rules you already have that these corrections hit.** If the corrections
173
+ came after you wrote the rule, the rule isn't working: it's too vague, or the
174
+ agent doesn't read it at the right moment. pushback can't know when you wrote
175
+ a rule, so check the evidence dates.
164
176
  - **New rules to consider.** Recurring corrections with no rule behind them.
165
177
 
166
178
  > [!TIP]
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pushback"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  description = "How often do you correct your coding agent, and at what kind of work? Measured from your own Claude Code transcripts."
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -0,0 +1 @@
1
+ __version__ = "0.1.3"
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import json
7
+ import math
7
8
  import os
8
9
  import sys
9
10
 
@@ -157,9 +158,11 @@ def cmd_rules(a):
157
158
  if a.from_response:
158
159
  with open(p["rules_items"], encoding="utf-8") as fh:
159
160
  items = json.load(fh)
160
- with open(a.from_response, encoding="utf-8") as fh:
161
- text = fh.read()
162
- raw = json.loads(text[text.find("{"): text.rfind("}") + 1]) # tolerate prose or fences around the JSON
161
+ raws = []
162
+ for path in a.from_response:
163
+ with open(path, encoding="utf-8") as fh:
164
+ text = fh.read()
165
+ raws.append(json.loads(text[text.find("{"): text.rfind("}") + 1])) # tolerate prose or fences
163
166
  else:
164
167
  if not os.path.exists(p["turns"]):
165
168
  sys.exit(f"{p['turns']} not found. Re-run `pushback extract`.")
@@ -184,20 +187,30 @@ def cmd_rules(a):
184
187
  print(f"prompt written to {p['rules_prompt']}")
185
188
  print("Give it to Claude (for example: ask Claude Code to answer the file), save the JSON reply,")
186
189
  print("then run: pushback rules --from-response <reply file>")
190
+ print("For stable rules, answer it 3 times in fresh sessions and pass all three replies.")
187
191
  return
188
192
 
189
193
  base = os.environ.get("ANTHROPIC_BASE_URL")
190
- print(f"Sending them to {base or 'the Anthropic API'} with model {a.model}.")
194
+ print(f"Sending them to {base or 'the Anthropic API'} with model {a.model}"
195
+ + (f", {a.runs} times" if a.runs > 1 else "") + ".")
191
196
  if not a.yes and input("Continue? [y/N] ").strip().lower() != "y":
192
197
  sys.exit("aborted")
193
198
  import anthropic
194
- raw = rules_mod.ask(anthropic.Anthropic(), request)
199
+ client = anthropic.Anthropic()
200
+ raws = [rules_mod.ask(client, request) for _ in range(a.runs)]
195
201
 
196
- found = rules_mod.parse(raw, items, a.min_support)
202
+ runs = [rules_mod.parse(raw, items, a.min_support) for raw in raws]
203
+ if len(runs) == 1:
204
+ found, unstable = runs[0], []
205
+ else:
206
+ min_runs = a.min_runs or max(1, math.ceil(2 * len(runs) / 3))
207
+ found, unstable = rules_mod.stabilize(runs, min_runs)
197
208
  with open(p["rules"], "w", encoding="utf-8") as fh:
198
- fh.write(rules_mod.render(found, len(items)))
209
+ fh.write(rules_mod.render(found, len(items), unstable, {x['id']: x.get('date', '') for x in items}))
199
210
  broken = sum(1 for r in found if r["covered_by"])
200
- print(f"{len(found)} rules ({broken} you already have but keep breaking) -> {p['rules']}")
211
+ print(f"{len(found)} rules ({broken} match rules you already have) -> {p['rules']}")
212
+ if len(runs) > 1:
213
+ print(f" kept rules that came back in at least {min_runs} of {len(runs)} runs; {len(unstable)} dropped as unstable")
201
214
 
202
215
 
203
216
  def main(argv=None):
@@ -245,7 +258,9 @@ def main(argv=None):
245
258
  ru.add_argument("--model", default=label_mod.DEFAULT_MODEL)
246
259
  ru.add_argument("--effort", default="high", choices=["low", "medium", "high"])
247
260
  ru.add_argument("--prompt-file", action="store_true", help="write the request to a file instead of calling the API")
248
- ru.add_argument("--from-response", help="read the model's JSON reply from this file")
261
+ ru.add_argument("--from-response", nargs="+", help="read the model's JSON reply (or several, for stability)")
262
+ ru.add_argument("--runs", type=int, default=1, help="ask N times and keep only rules that keep coming back")
263
+ ru.add_argument("--min-runs", type=int, help="runs a rule must appear in (default: two thirds of --runs)")
249
264
  ru.add_argument("-y", "--yes", action="store_true")
250
265
  ru.set_defaults(func=cmd_rules)
251
266
 
@@ -139,6 +139,7 @@ def _extract_file(path, session, out, max_user_chars, max_context_chars):
139
139
  "user": text[:max_user_chars].replace("\n", " "),
140
140
  })
141
141
  out.turns[f"{session}:{n}"] = {
142
+ "date": str(entry.get("timestamp", ""))[:10], # YYYY-MM-DD, for rule evidence dates
142
143
  "prompt": text,
143
144
  "prev_turn": "\n\n".join(turn_text),
144
145
  "prev_turn_tools": turn_tools,
@@ -1,154 +1,215 @@
1
- """Turn recurring corrections into rules you can paste into CLAUDE.md.
2
-
3
- The model groups corrections that share a cause and drafts one instruction per
4
- group. Two checks keep that honest:
5
-
6
- - Support is counted here from the correction ids the model cites, never taken
7
- from the model's own numbers, and a rule needs `min_support` real corrections.
8
- - Given your existing rule files, the model marks rules you already have. Those
9
- are the interesting ones: a rule that exists and still gets broken isn't working.
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- import json
15
-
16
- from .prompt import TASKS
17
-
18
- AGENT_CHARS = 500
19
- CORRECTION_CHARS = 400
20
-
21
- SYSTEM = """You are given corrections a user made to their AI agent. Each item has an id, the task type, the end of the agent's turn that was corrected, and the user's correction.
22
-
23
- Find recurring patterns: the same kind of mistake corrected at least 3 times. For each pattern, write one rule the agent can follow to avoid it.
24
-
25
- Rules:
26
- - Write each rule as an instruction for a CLAUDE.md file, in plain words. Make it specific and checkable. "Be careful" or "write better" is not a rule.
27
- - Generalise. Never include client names, company names, people's names, credentials or quoted message text in a rule.
28
- - "why" is one sentence on what kept going wrong.
29
- - "ids" lists every correction that supports the pattern. Only cite ids that really show the same mistake.
30
- - If existing rules are provided and one of them already asks for this, put a short quote of it (15 words max) in "covered_by". Otherwise leave "covered_by" as an empty string.
31
- - Prefer fewer, sharper rules. Skip one-off corrections."""
32
-
33
- SCHEMA = {
34
- "type": "object",
35
- "properties": {
36
- "rules": {
37
- "type": "array",
38
- "items": {
39
- "type": "object",
40
- "properties": {
41
- "rule": {"type": "string"},
42
- "why": {"type": "string"},
43
- "task": {"type": "string", "enum": TASKS},
44
- "ids": {"type": "array", "items": {"type": "integer"}},
45
- "covered_by": {"type": "string"},
46
- },
47
- "required": ["rule", "why", "task", "ids", "covered_by"],
48
- "additionalProperties": False,
49
- },
50
- }
51
- },
52
- "required": ["rules"],
53
- "additionalProperties": False,
54
- }
55
-
56
-
57
- def collect(labels: dict[str, dict], turns: dict[str, dict], tasks=None, ctypes=None, limit=400) -> list[dict]:
58
- """Every labelled correction with the context the model needs. Newest last, capped at `limit`."""
59
- items = []
60
- for mid, lab in labels.items():
61
- if not lab["correction"] or (tasks and lab["task"] not in tasks) or (ctypes and lab["ctype"] not in ctypes):
62
- continue
63
- turn = turns.get(mid, {})
64
- items.append({
65
- "id": mid,
66
- "task": lab["task"],
67
- "agent": turn.get("prev_turn", "")[-AGENT_CHARS:],
68
- "correction": turn.get("prompt", "")[:CORRECTION_CHARS],
69
- })
70
- items.sort(key=lambda x: (x["id"].rpartition(":")[0], int(x["id"].rpartition(":")[2])))
71
- return items[-limit:]
72
-
73
-
74
- def build_request(items: list[dict], existing: str, model: str, effort: str = "high") -> dict:
75
- payload = [{"n": n, "task": x["task"], "agent": x["agent"], "correction": x["correction"]}
76
- for n, x in enumerate(items)]
77
- content = "CORRECTIONS (the \"n\" field is the id to cite):\n" + json.dumps(payload, ensure_ascii=False)
78
- if existing.strip():
79
- content = "EXISTING RULES:\n" + existing.strip() + "\n\n" + content
80
- return {
81
- "model": model,
82
- "max_tokens": 16000,
83
- "system": SYSTEM,
84
- "messages": [{"role": "user", "content": content}],
85
- "output_config": {"effort": effort, "format": {"type": "json_schema", "schema": SCHEMA}},
86
- }
87
-
88
-
89
- def parse(raw: dict, items: list[dict], min_support: int = 3) -> list[dict]:
90
- """Map cited numbers back to correction ids, recount support, drop weak or malformed rules."""
91
- rules = []
92
- for r in raw.get("rules", []):
93
- if not isinstance(r, dict) or not str(r.get("rule", "")).strip():
94
- continue
95
- ids = []
96
- for i in r.get("ids", []):
97
- if isinstance(i, int) and 0 <= i < len(items) and items[i]["id"] not in ids:
98
- ids.append(items[i]["id"])
99
- if len(ids) < min_support:
100
- continue
101
- rules.append({
102
- "rule": r["rule"].strip(),
103
- "why": str(r.get("why", "")).strip(),
104
- "task": r.get("task") if r.get("task") in TASKS else "meta",
105
- "support": len(ids),
106
- "ids": ids,
107
- "covered_by": str(r.get("covered_by", "")).strip(),
108
- })
109
- rules.sort(key=lambda r: r["support"], reverse=True)
110
- return rules
111
-
112
-
113
- def ask(client, request: dict) -> dict:
114
- response = client.messages.create(**request)
115
- if response.stop_reason == "refusal":
116
- raise RuntimeError("model declined (stop_reason=refusal)")
117
- if response.stop_reason == "max_tokens":
118
- raise RuntimeError("output hit max_tokens; narrow the input with --task or --limit")
119
- text = next((b.text for b in response.content if b.type == "text"), "")
120
- return json.loads(text)
121
-
122
-
123
- def _short(message_id: str) -> str:
124
- """session-uuid:12 -> first 8 chars of the session, which is enough to find it."""
125
- session, _, n = message_id.rpartition(":")
126
- return f"{session[:8]}:{n}"
127
-
128
-
129
- def render(rules: list[dict], n_corrections: int) -> str:
130
- broken = [r for r in rules if r["covered_by"]]
131
- new = [r for r in rules if not r["covered_by"]]
132
- out = [f"# Rules from {n_corrections} corrections", ""]
133
- out.append("Each rule is backed by at least the number of corrections shown. Read the evidence ids "
134
- "in your own data before adopting a rule; the model drafted these, you decide.")
135
-
136
- def block(title, note, group):
137
- if not group:
138
- return
139
- out.extend(["", f"## {title}", "", note, ""])
140
- for r in group:
141
- out.append(f"- **{r['rule']}** ")
142
- out.append(f" {r['why']} ({r['task']}, {r['support']} corrections)")
143
- if r["covered_by"]:
144
- out.append(f" You already have: \"{r['covered_by']}\"")
145
- shown = [_short(i) for i in r["ids"][:8]]
146
- out.append(f" Evidence: {', '.join(shown)}" + (" …" if len(r["ids"]) > 8 else ""))
147
-
148
- block("Rules you already have that keep getting broken",
149
- "A rule that exists and still gets corrected isn't working. Make it more specific, "
150
- "move it somewhere the agent reads at the right moment, or turn it into a check.", broken)
151
- block("New rules to consider", "Recurring corrections with no rule behind them yet.", new)
152
- if not rules:
153
- out.extend(["", "No pattern reached the minimum support. Try more data or a lower --min-support."])
154
- return "\n".join(out) + "\n"
1
+ """Turn recurring corrections into rules you can paste into CLAUDE.md.
2
+
3
+ The model groups corrections that share a cause and drafts one instruction per
4
+ group. Two checks keep that honest:
5
+
6
+ - Support is counted here from the correction ids the model cites, never taken
7
+ from the model's own numbers, and a rule needs `min_support` real corrections.
8
+ - Given your existing rule files, the model marks rules you already have. Those
9
+ are the interesting ones: a rule that exists and still gets broken isn't working.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+
16
+ from .prompt import TASKS
17
+
18
+ AGENT_CHARS = 500
19
+ CORRECTION_CHARS = 400
20
+
21
+ SYSTEM = """You are given corrections a user made to their AI agent. Each item has an id, the task type, the end of the agent's turn that was corrected, and the user's correction.
22
+
23
+ Find recurring patterns: the same kind of mistake corrected at least 3 times. For each pattern, write one rule the agent can follow to avoid it.
24
+
25
+ Rules:
26
+ - Write each rule as an instruction for a CLAUDE.md file, in plain words. Make it specific and checkable. "Be careful" or "write better" is not a rule.
27
+ - Generalise. Never include client names, company names, people's names, credentials or quoted message text in a rule.
28
+ - "why" is one sentence on what kept going wrong.
29
+ - "ids" lists every correction that supports the pattern. Only cite ids that really show the same mistake.
30
+ - If existing rules are provided and one of them already asks for this, put a short quote of it (15 words max) in "covered_by". Otherwise leave "covered_by" as an empty string.
31
+ - Prefer fewer, sharper rules. Skip one-off corrections."""
32
+
33
+ SCHEMA = {
34
+ "type": "object",
35
+ "properties": {
36
+ "rules": {
37
+ "type": "array",
38
+ "items": {
39
+ "type": "object",
40
+ "properties": {
41
+ "rule": {"type": "string"},
42
+ "why": {"type": "string"},
43
+ "task": {"type": "string", "enum": TASKS},
44
+ "ids": {"type": "array", "items": {"type": "integer"}},
45
+ "covered_by": {"type": "string"},
46
+ },
47
+ "required": ["rule", "why", "task", "ids", "covered_by"],
48
+ "additionalProperties": False,
49
+ },
50
+ }
51
+ },
52
+ "required": ["rules"],
53
+ "additionalProperties": False,
54
+ }
55
+
56
+
57
+ def collect(labels: dict[str, dict], turns: dict[str, dict], tasks=None, ctypes=None, limit=400) -> list[dict]:
58
+ """Every labelled correction with the context the model needs. Newest last, capped at `limit`."""
59
+ items = []
60
+ for mid, lab in labels.items():
61
+ if not lab["correction"] or (tasks and lab["task"] not in tasks) or (ctypes and lab["ctype"] not in ctypes):
62
+ continue
63
+ turn = turns.get(mid, {})
64
+ items.append({
65
+ "id": mid,
66
+ "task": lab["task"],
67
+ "agent": turn.get("prev_turn", "")[-AGENT_CHARS:],
68
+ "correction": turn.get("prompt", "")[:CORRECTION_CHARS],
69
+ "date": turn.get("date", ""),
70
+ })
71
+ items.sort(key=lambda x: (x["id"].rpartition(":")[0], int(x["id"].rpartition(":")[2])))
72
+ return items[-limit:]
73
+
74
+
75
+ def build_request(items: list[dict], existing: str, model: str, effort: str = "high") -> dict:
76
+ payload = [{"n": n, "task": x["task"], "agent": x["agent"], "correction": x["correction"]}
77
+ for n, x in enumerate(items)]
78
+ content = "CORRECTIONS (the \"n\" field is the id to cite):\n" + json.dumps(payload, ensure_ascii=False)
79
+ if existing.strip():
80
+ content = "EXISTING RULES:\n" + existing.strip() + "\n\n" + content
81
+ return {
82
+ "model": model,
83
+ "max_tokens": 16000,
84
+ "system": SYSTEM,
85
+ "messages": [{"role": "user", "content": content}],
86
+ "output_config": {"effort": effort, "format": {"type": "json_schema", "schema": SCHEMA}},
87
+ }
88
+
89
+
90
+ def parse(raw: dict, items: list[dict], min_support: int = 3) -> list[dict]:
91
+ """Map cited numbers back to correction ids, recount support, drop weak or malformed rules."""
92
+ rules = []
93
+ for r in raw.get("rules", []):
94
+ if not isinstance(r, dict) or not str(r.get("rule", "")).strip():
95
+ continue
96
+ ids = []
97
+ for i in r.get("ids", []):
98
+ if isinstance(i, int) and 0 <= i < len(items) and items[i]["id"] not in ids:
99
+ ids.append(items[i]["id"])
100
+ if len(ids) < min_support:
101
+ continue
102
+ rules.append({
103
+ "rule": r["rule"].strip(),
104
+ "why": str(r.get("why", "")).strip(),
105
+ "task": r.get("task") if r.get("task") in TASKS else "meta",
106
+ "support": len(ids),
107
+ "ids": ids,
108
+ "covered_by": str(r.get("covered_by", "")).strip(),
109
+ })
110
+ rules.sort(key=lambda r: r["support"], reverse=True)
111
+ return rules
112
+
113
+
114
+ def ask(client, request: dict) -> dict:
115
+ response = client.messages.create(**request)
116
+ if response.stop_reason == "refusal":
117
+ raise RuntimeError("model declined (stop_reason=refusal)")
118
+ if response.stop_reason == "max_tokens":
119
+ raise RuntimeError("output hit max_tokens; narrow the input with --task or --limit")
120
+ text = next((b.text for b in response.content if b.type == "text"), "")
121
+ return json.loads(text)
122
+
123
+
124
+ def stabilize(runs: list[list[dict]], min_runs: int, min_overlap: float = 0.5) -> tuple[list[dict], list[dict]]:
125
+ """Group the same rule across several runs, and keep only the rules that keep coming back.
126
+
127
+ Rules are matched by their evidence, not their wording: two rules from different runs are the
128
+ same rule when their cited corrections overlap by at least `min_overlap` (the share of the
129
+ smaller set). A cluster takes at most one rule per run. Its text comes from its best-supported
130
+ member; its evidence is the corrections cited by at least half of its members.
131
+ Returns (stable, unstable), each rule annotated with how many runs it appeared in.
132
+ """
133
+ clusters: list[dict] = []
134
+ members = sorted(((ri, r) for ri, rs in enumerate(runs) for r in rs), key=lambda x: -x[1]["support"])
135
+ for ri, r in members:
136
+ ids = set(r["ids"])
137
+ best, best_overlap = None, 0.0
138
+ for c in clusters:
139
+ if ri in c["runs"]:
140
+ continue
141
+ overlap = len(ids & c["ids"]) / min(len(ids), len(c["ids"]))
142
+ if overlap >= min_overlap and overlap > best_overlap:
143
+ best, best_overlap = c, overlap
144
+ if best is None:
145
+ clusters.append({"runs": {ri}, "ids": set(ids), "rules": [r]})
146
+ else:
147
+ best["runs"].add(ri)
148
+ best["ids"] |= ids
149
+ best["rules"].append(r)
150
+
151
+ stable, unstable = [], []
152
+ for c in clusters:
153
+ rep = max(c["rules"], key=lambda r: r["support"])
154
+ counts: dict[str, int] = {}
155
+ for r in c["rules"]:
156
+ for i in r["ids"]:
157
+ counts[i] = counts.get(i, 0) + 1
158
+ need = (len(c["rules"]) + 1) // 2
159
+ core = [i for i, k in sorted(counts.items(), key=lambda kv: -kv[1]) if k >= need]
160
+ covered = [r["covered_by"] for r in c["rules"] if r["covered_by"]]
161
+ out = {**rep, "ids": core, "support": len(core), "runs": len(c["runs"]), "n_runs": len(runs),
162
+ "covered_by": covered[0] if len(covered) * 2 >= len(c["rules"]) else ""}
163
+ (stable if out["runs"] >= min_runs else unstable).append(out)
164
+ stable.sort(key=lambda r: (r["runs"], r["support"]), reverse=True)
165
+ unstable.sort(key=lambda r: (r["runs"], r["support"]), reverse=True)
166
+ return stable, unstable
167
+
168
+
169
+ def _short(message_id: str) -> str:
170
+ """session-uuid:12 -> first 8 chars of the session, which is enough to find it."""
171
+ session, _, n = message_id.rpartition(":")
172
+ return f"{session[:8]}:{n}"
173
+
174
+
175
+ def render(rules: list[dict], n_corrections: int, unstable: list[dict] | None = None,
176
+ dates: dict[str, str] | None = None) -> str:
177
+ broken = [r for r in rules if r["covered_by"]]
178
+ new = [r for r in rules if not r["covered_by"]]
179
+ out = [f"# Rules from {n_corrections} corrections", ""]
180
+ out.append("Each rule is backed by at least the number of corrections shown. Read the evidence ids "
181
+ "in your own data before adopting a rule; the model drafted these, you decide.")
182
+ n_runs = rules[0]["n_runs"] if rules and "n_runs" in rules[0] else (unstable[0]["n_runs"] if unstable else 1)
183
+ if n_runs > 1:
184
+ out.append("")
185
+ out.append(f"Asked {n_runs} times. Only rules that came back in several runs, citing mostly the same "
186
+ "corrections, are kept; the rest are listed at the end.")
187
+
188
+ def block(title, note, group):
189
+ if not group:
190
+ return
191
+ out.extend(["", f"## {title}", "", note, ""])
192
+ for r in group:
193
+ out.append(f"- **{r['rule']}** ")
194
+ runs = f", in {r['runs']}/{r['n_runs']} runs" if r.get("n_runs", 1) > 1 else ""
195
+ out.append(f" {r['why']} ({r['task']}, {r['support']} corrections{runs})")
196
+ if r["covered_by"]:
197
+ out.append(f" You already have: \"{r['covered_by']}\"")
198
+ shown = [_short(i) for i in r["ids"][:8]]
199
+ when = sorted(dates[i] for i in r["ids"] if dates and dates.get(i))
200
+ span = f" ({when[0]} to {when[-1]})" if when else ""
201
+ out.append(f" Evidence{span}: {', '.join(shown)}" + (" …" if len(r["ids"]) > 8 else ""))
202
+
203
+ block("Rules you already have that these corrections hit",
204
+ "If these corrections happened after you wrote the rule, the rule isn't working: make it more specific, "
205
+ "move it somewhere the agent reads at the right moment, or turn it into a check. If you wrote the rule "
206
+ "after them, it may already be doing its job; check the evidence dates.", broken)
207
+ block("New rules to consider", "Recurring corrections with no rule behind them yet.", new)
208
+ if unstable:
209
+ out.extend(["", "## Not stable enough to keep", "",
210
+ "These showed up in too few runs. They may still be real; they're just not reliable yet.", ""])
211
+ for r in unstable:
212
+ out.append(f"- {r['rule']} ({r['runs']}/{r['n_runs']} runs, {r['support']} corrections)")
213
+ if not rules:
214
+ out.extend(["", "No pattern reached the minimum support. Try more data or a lower --min-support."])
215
+ return "\n".join(out) + "\n"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pushback
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: How often do you correct your coding agent, and at what kind of work? Measured from your own Claude Code transcripts.
5
5
  Author: Intikhab Azam
6
6
  License: MIT
@@ -54,7 +54,7 @@ shows what you use your AI coding agent for, how often you correct it on each
54
54
  kind of work (code, writing, media, research, ops, meta), and how often a
55
55
  correction needs correcting again. It drafts
56
56
  **CLAUDE.md rules** from the corrections you keep repeating, shows which of
57
- your existing rules keep getting broken, and exports your corrections as
57
+ your existing rules those corrections run into, and exports your corrections as
58
58
  **preference pairs** (prompt / chosen / rejected). A hand-audit step tells you
59
59
  how far to trust every number.
60
60
 
@@ -67,7 +67,7 @@ pip install pushback
67
67
  The author's own 1,633 messages to Claude Code:
68
68
 
69
69
  <p align="center">
70
- <img src="assets/rates.svg" width="100%" alt="Correction rate by task: media 43.0%, writing 24.7%, research 6.9%, code 5.9%, meta 4.7%, ops 4.5%. One in three corrections needed a second correction. Three existing rules kept getting broken.">
70
+ <img src="assets/rates.svg" width="100%" alt="Correction rate by task: media 43.0%, writing 24.7%, research 6.9%, code 5.9%, meta 4.7%, ops 4.5%. One in three corrections needed a second correction. One existing rule kept getting broken after it was written.">
71
71
  </p>
72
72
 
73
73
  | task | messages | share of use | corrections | rate | 95% CI | corrected again |
@@ -94,7 +94,7 @@ Most corrected topics (at least 5 messages each):
94
94
 
95
95
  - Against 91 hand-checked messages the correction labels had **precision 0.97 and recall 0.80**. Topic labels were spot-checked, not audited.
96
96
  - **1 in 3 corrections needed a second one.** 70 of 201 fixes got corrected again (35%), 41% on writing and 44% on images.
97
- - **3 rules the author had already written kept getting broken**, with 6 to 8 corrections each.
97
+ - **1 rule the author had already written kept getting broken**: "images must not look AI-generated" was corrected 3 more times after it was written. Two other matching rules turned out to be written *because of* the corrections, which is why every rule now shows its evidence dates.
98
98
 
99
99
  > [!NOTE]
100
100
  > These rates describe a workflow, not a model. The author's code work runs
@@ -177,17 +177,29 @@ pushback rules # drafts rules into pushback-dat
177
177
  pushback rules --existing CLAUDE.md notes/*.md # also check against the rules you already have
178
178
  pushback rules --prompt-file # no API key? writes the request to a file instead
179
179
  pushback rules --from-response reply.json # ...and reads Claude's answer back
180
+ pushback rules --runs 3 # ask 3 times, keep only the rules that keep coming back
180
181
  ```
181
182
 
182
183
  The model groups corrections that share a cause and drafts one CLAUDE.md
183
184
  instruction per group. Support is counted from the correction ids it cites,
184
185
  not from its own numbers, and a rule needs at least 3 real corrections.
185
186
 
187
+ **Stable rules.** A model won't write the same rules twice. With `--runs 3`
188
+ (or three replies passed to `--from-response`), pushback matches rules across
189
+ runs by the corrections they cite, not by their wording, and keeps only the
190
+ ones that come back in at least 2 of 3 runs. Each rule shows how many runs it
191
+ appeared in, and the unstable ones are listed separately instead of vanishing.
192
+
193
+ On the author's 214 corrections, three independent runs wrote 12, 10 and 7
194
+ rules: 16 distinct rules in total. 5 came back in all three runs, 3 in two,
195
+ and **8 showed up only once**. A single run mixes real rules with one-offs.
196
+
186
197
  When you pass your existing rule files, the output splits in two:
187
198
 
188
- - **Rules you already have that keep getting broken.** These are the useful
189
- ones. A rule that exists and still gets corrected isn't working: it's too
190
- vague, or the agent doesn't read it at the right moment.
199
+ - **Rules you already have that these corrections hit.** If the corrections
200
+ came after you wrote the rule, the rule isn't working: it's too vague, or the
201
+ agent doesn't read it at the right moment. pushback can't know when you wrote
202
+ a rule, so check the evidence dates.
191
203
  - **New rules to consider.** Recurring corrections with no rule behind them.
192
204
 
193
205
  > [!TIP]
@@ -309,7 +309,7 @@ def test_rules_render_separates_broken_rules():
309
309
  {"rule": "B", "why": "w", "task": "media", "support": 3, "ids": ["s:2"], "covered_by": ""},
310
310
  ]
311
311
  md = rules.render(found, 40)
312
- assert md.index("keep getting broken") < md.index("**A**") < md.index("New rules") < md.index("**B**")
312
+ assert md.index("these corrections hit") < md.index("**A**") < md.index("New rules") < md.index("**B**")
313
313
  assert "No pattern" in rules.render([], 3)
314
314
 
315
315
 
@@ -337,7 +337,7 @@ def test_rules_cli_round_trip_without_api(tmp_path, capsys):
337
337
  cli.main(["--data", str(d), "rules", "--from-response", str(reply)])
338
338
  md = (d / "rules.md").read_text(encoding="utf-8")
339
339
  assert "Keep posts under 80 words" in md and "s:1, s:3, s:5" in md
340
- assert "1 rules (1 you already have" in capsys.readouterr().out
340
+ assert "1 rules (1 match rules you already have" in capsys.readouterr().out
341
341
 
342
342
 
343
343
  def test_rules_render_shortens_session_ids():
@@ -388,71 +388,144 @@ def test_prompt_topics_are_in_schema_and_system():
388
388
  from pushback import prompt
389
389
  assert "cli-tool" in prompt.SYSTEM and "cli-tool" in prompt.ALL_TOPICS
390
390
  assert all("other" in ts for ts in prompt.TOPICS.values())
391
-
392
-
393
- # --- label without an API key -----------------------------------------------
394
-
395
- def _label_data(tmp_path, n=5):
396
- d = tmp_path / "data"
397
- d.mkdir()
398
- with open(d / "messages.jsonl", "w", encoding="utf-8") as fh:
399
- for i in range(n):
400
- fh.write(json.dumps({"id": f"s:{i}", "session": "s", "prev_assistant": f"agent {i}",
401
- "user": f"user {i}"}) + "\n")
402
- return d
403
-
404
-
405
- def _answer(ids, correction=False):
406
- return json.dumps({"labels": [{"id": i, "task": "code", "topic": "cli-tool", "correction": correction,
407
- "ctype": "code" if correction else "none", "conf": "high"} for i in ids]})
408
-
409
-
410
- def test_label_prompt_file_round_trip(tmp_path, capsys):
411
- d = _label_data(tmp_path, 5)
412
- cli.main(["--data", str(d), "label", "--prompt-file", "--batch-size", "2"])
413
- prompts, answers = d / "label-prompts", d / "label-responses"
414
- assert sorted(x.name for x in prompts.glob("batch_*.md")) == ["batch_000.md", "batch_001.md", "batch_002.md"]
415
- instructions = (prompts / "INSTRUCTIONS.md").read_text(encoding="utf-8")
416
- assert "cli-tool" in instructions and str(answers.resolve()).replace("\\", "/") in instructions
417
- assert "No API key needed" in capsys.readouterr().out
418
- assert "s:0" not in (prompts / "batch_000.md").read_text(encoding="utf-8") # the model never sees real ids
419
-
420
- (answers / "batch_000.json").write_text("Here are the labels:\n```json\n" + _answer([0, 1], True) + "\n```",
421
- encoding="utf-8")
422
- (answers / "batch_001.json").write_text("not json at all", encoding="utf-8")
423
- cli.main(["--data", str(d), "label", "--from-responses"])
424
- out = capsys.readouterr().out
425
- assert "1 batches read, 2 new labels" in out and "batch_001" in out and "not answered yet: 1" in out
426
- labels = label.load_labels(str(d / "labels.jsonl"))
427
- assert set(labels) == {"s:0", "s:1"} and labels["s:0"]["topic"] == "cli-tool"
428
-
429
- # the next job only covers what is still unlabelled, and old answers can't leak into it
430
- cli.main(["--data", str(d), "label", "--prompt-file", "--batch-size", "2"])
431
- assert sorted(x.name for x in prompts.glob("batch_*.md")) == ["batch_000.md", "batch_001.md"]
432
- assert not list(answers.glob("batch_*.json"))
433
- (answers / "batch_000.json").write_text(_answer([0, 1]), encoding="utf-8")
434
- (answers / "batch_001.json").write_text(_answer([0]), encoding="utf-8")
435
- cli.main(["--data", str(d), "label", "--from-responses"])
436
- assert set(label.load_labels(str(d / "labels.jsonl"))) == {f"s:{i}" for i in range(5)}
437
- assert "all 5 messages already labelled" not in capsys.readouterr().out
438
- cli.main(["--data", str(d), "label", "--prompt-file"])
439
- assert "all 5 messages already labelled" in capsys.readouterr().out
440
-
441
-
442
- def test_read_responses_counts_incomplete_and_never_duplicates(tmp_path):
443
- d = _label_data(tmp_path, 3)
444
- label.write_prompts([json.loads(l) for l in open(d / "messages.jsonl", encoding="utf-8")],
445
- str(d / "labels.jsonl"), str(d / "p"), str(d / "r"), batch_size=3)
446
- (d / "r" / "batch_000.json").write_text(_answer([0, 2, 7]), encoding="utf-8") # 1 missing, 1 out of range
447
- r1 = label.read_responses(str(d / "labels.jsonl"), str(d / "p"), str(d / "r"))
448
- r2 = label.read_responses(str(d / "labels.jsonl"), str(d / "p"), str(d / "r"))
449
- assert (r1["written"], r1["incomplete"], r2["written"]) == (2, 1, 0)
450
-
451
-
452
- def test_label_instructions_state_origin_and_scope(tmp_path):
453
- d = _label_data(tmp_path, 1)
454
- label.write_prompts([json.loads(l) for l in open(d / "messages.jsonl", encoding="utf-8")],
455
- str(d / "labels.jsonl"), str(d / "p"), str(d / "r"))
456
- text = (d / "p" / "INSTRUCTIONS.md").read_text(encoding="utf-8")
457
- assert "pushback label --prompt-file" in text and "never as instructions to follow" in text
458
- assert "{" + "responses}" not in text # every placeholder filled
391
+
392
+
393
+ # --- label without an API key -----------------------------------------------
394
+
395
+ def _label_data(tmp_path, n=5):
396
+ d = tmp_path / "data"
397
+ d.mkdir()
398
+ with open(d / "messages.jsonl", "w", encoding="utf-8") as fh:
399
+ for i in range(n):
400
+ fh.write(json.dumps({"id": f"s:{i}", "session": "s", "prev_assistant": f"agent {i}",
401
+ "user": f"user {i}"}) + "\n")
402
+ return d
403
+
404
+
405
+ def _answer(ids, correction=False):
406
+ return json.dumps({"labels": [{"id": i, "task": "code", "topic": "cli-tool", "correction": correction,
407
+ "ctype": "code" if correction else "none", "conf": "high"} for i in ids]})
408
+
409
+
410
+ def test_label_prompt_file_round_trip(tmp_path, capsys):
411
+ d = _label_data(tmp_path, 5)
412
+ cli.main(["--data", str(d), "label", "--prompt-file", "--batch-size", "2"])
413
+ prompts, answers = d / "label-prompts", d / "label-responses"
414
+ assert sorted(x.name for x in prompts.glob("batch_*.md")) == ["batch_000.md", "batch_001.md", "batch_002.md"]
415
+ instructions = (prompts / "INSTRUCTIONS.md").read_text(encoding="utf-8")
416
+ assert "cli-tool" in instructions and str(answers.resolve()).replace("\\", "/") in instructions
417
+ assert "No API key needed" in capsys.readouterr().out
418
+ assert "s:0" not in (prompts / "batch_000.md").read_text(encoding="utf-8") # the model never sees real ids
419
+
420
+ (answers / "batch_000.json").write_text("Here are the labels:\n```json\n" + _answer([0, 1], True) + "\n```",
421
+ encoding="utf-8")
422
+ (answers / "batch_001.json").write_text("not json at all", encoding="utf-8")
423
+ cli.main(["--data", str(d), "label", "--from-responses"])
424
+ out = capsys.readouterr().out
425
+ assert "1 batches read, 2 new labels" in out and "batch_001" in out and "not answered yet: 1" in out
426
+ labels = label.load_labels(str(d / "labels.jsonl"))
427
+ assert set(labels) == {"s:0", "s:1"} and labels["s:0"]["topic"] == "cli-tool"
428
+
429
+ # the next job only covers what is still unlabelled, and old answers can't leak into it
430
+ cli.main(["--data", str(d), "label", "--prompt-file", "--batch-size", "2"])
431
+ assert sorted(x.name for x in prompts.glob("batch_*.md")) == ["batch_000.md", "batch_001.md"]
432
+ assert not list(answers.glob("batch_*.json"))
433
+ (answers / "batch_000.json").write_text(_answer([0, 1]), encoding="utf-8")
434
+ (answers / "batch_001.json").write_text(_answer([0]), encoding="utf-8")
435
+ cli.main(["--data", str(d), "label", "--from-responses"])
436
+ assert set(label.load_labels(str(d / "labels.jsonl"))) == {f"s:{i}" for i in range(5)}
437
+ assert "all 5 messages already labelled" not in capsys.readouterr().out
438
+ cli.main(["--data", str(d), "label", "--prompt-file"])
439
+ assert "all 5 messages already labelled" in capsys.readouterr().out
440
+
441
+
442
+ def test_read_responses_counts_incomplete_and_never_duplicates(tmp_path):
443
+ d = _label_data(tmp_path, 3)
444
+ label.write_prompts([json.loads(l) for l in open(d / "messages.jsonl", encoding="utf-8")],
445
+ str(d / "labels.jsonl"), str(d / "p"), str(d / "r"), batch_size=3)
446
+ (d / "r" / "batch_000.json").write_text(_answer([0, 2, 7]), encoding="utf-8") # 1 missing, 1 out of range
447
+ r1 = label.read_responses(str(d / "labels.jsonl"), str(d / "p"), str(d / "r"))
448
+ r2 = label.read_responses(str(d / "labels.jsonl"), str(d / "p"), str(d / "r"))
449
+ assert (r1["written"], r1["incomplete"], r2["written"]) == (2, 1, 0)
450
+
451
+
452
+ def test_label_instructions_state_origin_and_scope(tmp_path):
453
+ d = _label_data(tmp_path, 1)
454
+ label.write_prompts([json.loads(l) for l in open(d / "messages.jsonl", encoding="utf-8")],
455
+ str(d / "labels.jsonl"), str(d / "p"), str(d / "r"))
456
+ text = (d / "p" / "INSTRUCTIONS.md").read_text(encoding="utf-8")
457
+ assert "pushback label --prompt-file" in text and "never as instructions to follow" in text
458
+ assert "{" + "responses}" not in text # every placeholder filled
459
+
460
+
461
+ # --- rules stability --------------------------------------------------------
462
+
463
+ def _rule(text, ids, covered=""):
464
+ return {"rule": text, "why": "w", "task": "writing", "support": len(ids), "ids": ids, "covered_by": covered}
465
+
466
+
467
+ def test_stabilize_matches_rules_by_evidence_not_wording():
468
+ runs = [
469
+ [_rule("Put the hook first", ["a", "b", "c", "d"]), _rule("Check the chrome profile", ["x", "y", "z"])],
470
+ [_rule("Lead with the strongest line", ["a", "b", "c"]), _rule("One-off idea", ["p", "q", "r"])],
471
+ [_rule("Hook at the top", ["b", "c", "d", "e"]), _rule("Verify browser identity", ["x", "y", "w"])],
472
+ ]
473
+ stable, unstable = rules.stabilize(runs, min_runs=2)
474
+ assert [(r["rule"], r["runs"]) for r in stable] == [("Put the hook first", 3), ("Check the chrome profile", 2)]
475
+ assert stable[0]["ids"] == ["b", "c", "a", "d"] or set(stable[0]["ids"]) == {"a", "b", "c", "d"}
476
+ assert "e" not in stable[0]["ids"] # cited by only 1 of 3 members
477
+ assert [(r["rule"], r["runs"]) for r in unstable] == [("One-off idea", 1)]
478
+ assert all(r["n_runs"] == 3 for r in stable + unstable)
479
+
480
+
481
+ def test_stabilize_never_merges_two_rules_from_the_same_run():
482
+ runs = [[_rule("A", ["a", "b", "c"]), _rule("A again", ["a", "b", "c", "d"])], [_rule("A'", ["a", "b", "c"])]]
483
+ stable, unstable = rules.stabilize(runs, min_runs=2)
484
+ assert len(stable) == 1 and len(unstable) == 1
485
+
486
+
487
+ def test_stabilize_covered_by_needs_half_the_members():
488
+ runs = [[_rule("R", ["a", "b", "c"], "no tables")], [_rule("R", ["a", "b", "c"])], [_rule("R", ["a", "b", "c"])]]
489
+ assert rules.stabilize(runs, 2)[0][0]["covered_by"] == ""
490
+ runs[1][0]["covered_by"] = "no tables"
491
+ assert rules.stabilize(runs, 2)[0][0]["covered_by"] == "no tables"
492
+
493
+
494
+ def test_rules_cli_multiple_responses_reports_stability(tmp_path, capsys):
495
+ labels, turns = _rules_fixture()
496
+ d = tmp_path / "data"
497
+ d.mkdir()
498
+ with open(d / "labels.jsonl", "w", encoding="utf-8") as fh:
499
+ for mid, lab in labels.items():
500
+ fh.write(json.dumps({"id": mid, **lab}) + "\n")
501
+ with open(d / "turns.jsonl", "w", encoding="utf-8") as fh:
502
+ for mid, t in turns.items():
503
+ fh.write(json.dumps({"id": mid, **t}) + "\n")
504
+ cli.main(["--data", str(d), "rules", "--prompt-file", "--existing"])
505
+ replies = []
506
+ for k, rs in enumerate([
507
+ [{"rule": "Keep posts short", "ids": [0, 1, 2]}, {"rule": "Fluke", "ids": [2, 3, 4]}],
508
+ [{"rule": "Posts under 80 words", "ids": [0, 1, 2]}],
509
+ [{"rule": "Short posts", "ids": [0, 1, 3]}],
510
+ ]):
511
+ f = tmp_path / f"r{k}.json"
512
+ f.write_text(json.dumps({"rules": [{**r, "why": "w", "task": "writing", "covered_by": ""} for r in rs]}),
513
+ encoding="utf-8")
514
+ replies.append(str(f))
515
+ cli.main(["--data", str(d), "rules", "--from-response", *replies])
516
+ out = capsys.readouterr().out
517
+ assert "1 rules" in out and "at least 2 of 3 runs; 1 dropped" in out
518
+ md = (d / "rules.md").read_text(encoding="utf-8")
519
+ assert "in 3/3 runs" in md and "Not stable enough" in md and "Fluke (1/3 runs" in md
520
+
521
+
522
+ def test_rules_evidence_shows_date_range(tmp_path):
523
+ found = [{"rule": "A", "why": "w", "task": "ops", "support": 3, "ids": ["s:1", "s:2", "s:3"], "covered_by": "x"}]
524
+ md = rules.render(found, 3, dates={"s:1": "2026-09-03", "s:2": "2026-08-27", "s:3": ""})
525
+ assert "Evidence (2026-08-27 to 2026-09-03):" in md
526
+ assert "Evidence:" in rules.render(found, 3) # no dates known, no range
527
+
528
+
529
+ def test_extract_records_message_dates(tmp_path):
530
+ _write_session(tmp_path, "d", [dict(_user("hello"), timestamp="2026-09-21T10:00:00.000Z")])
531
+ assert extract.extract(str(tmp_path)).turns["d:0"]["date"] == "2026-09-21"
@@ -1 +0,0 @@
1
- __version__ = "0.1.2"
File without changes
File without changes
File without changes
File without changes
File without changes