errlore 0.1.0__tar.gz → 0.1.2__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 (56) hide show
  1. {errlore-0.1.0 → errlore-0.1.2}/.github/workflows/ci.yml +1 -1
  2. {errlore-0.1.0 → errlore-0.1.2}/CHANGELOG.md +25 -0
  3. {errlore-0.1.0 → errlore-0.1.2}/PKG-INFO +15 -5
  4. {errlore-0.1.0 → errlore-0.1.2}/README.md +6 -2
  5. errlore-0.1.2/SECURITY.md +15 -0
  6. errlore-0.1.2/benchmarks/bench_error_reduction.py +480 -0
  7. errlore-0.1.2/benchmarks/results/error_reduction/report.md +29 -0
  8. errlore-0.1.2/examples/claude-code/README.md +23 -0
  9. errlore-0.1.2/examples/claude-code/errlore_posttooluse.py +44 -0
  10. errlore-0.1.2/examples/claude-code/errlore_sessionstart.py +26 -0
  11. errlore-0.1.2/examples/claude-code/settings.json.example +25 -0
  12. {errlore-0.1.0 → errlore-0.1.2}/integrations/openwebui/README.md +2 -3
  13. {errlore-0.1.0 → errlore-0.1.2}/pyproject.toml +13 -5
  14. errlore-0.1.2/site/demo.gif +0 -0
  15. errlore-0.1.2/site/demo_script.py +91 -0
  16. errlore-0.1.2/site/index.html +262 -0
  17. errlore-0.1.2/site/llms.txt +31 -0
  18. errlore-0.1.2/site/og.png +0 -0
  19. {errlore-0.1.0 → errlore-0.1.2}/src/errlore/__init__.py +1 -1
  20. {errlore-0.1.0 → errlore-0.1.2}/src/errlore/errmem/tracker.py +2 -2
  21. {errlore-0.1.0 → errlore-0.1.2}/src/errlore/lessons/models.py +2 -2
  22. {errlore-0.1.0 → errlore-0.1.2}/src/errlore/retrieval/index.py +8 -6
  23. {errlore-0.1.0 → errlore-0.1.2}/.gitignore +0 -0
  24. {errlore-0.1.0 → errlore-0.1.2}/LICENSE +0 -0
  25. {errlore-0.1.0 → errlore-0.1.2}/benchmarks/bench_retrieval.py +0 -0
  26. {errlore-0.1.0 → errlore-0.1.2}/examples/anthropic_agent.py +0 -0
  27. {errlore-0.1.0 → errlore-0.1.2}/examples/langchain_agent.py +0 -0
  28. {errlore-0.1.0 → errlore-0.1.2}/examples/openai_agent.py +0 -0
  29. {errlore-0.1.0 → errlore-0.1.2}/integrations/openwebui/errlore_feedback_action.py +0 -0
  30. {errlore-0.1.0 → errlore-0.1.2}/integrations/openwebui/errlore_memory_filter.py +0 -0
  31. {errlore-0.1.0 → errlore-0.1.2}/src/errlore/errmem/__init__.py +0 -0
  32. {errlore-0.1.0 → errlore-0.1.2}/src/errlore/errmem/classifier.py +0 -0
  33. {errlore-0.1.0 → errlore-0.1.2}/src/errlore/errmem/injector.py +0 -0
  34. {errlore-0.1.0 → errlore-0.1.2}/src/errlore/errmem/patterns.py +0 -0
  35. {errlore-0.1.0 → errlore-0.1.2}/src/errlore/facade.py +0 -0
  36. {errlore-0.1.0 → errlore-0.1.2}/src/errlore/io/__init__.py +0 -0
  37. {errlore-0.1.0 → errlore-0.1.2}/src/errlore/io/jsonl_index.py +0 -0
  38. {errlore-0.1.0 → errlore-0.1.2}/src/errlore/io/jsonl_writer.py +0 -0
  39. {errlore-0.1.0 → errlore-0.1.2}/src/errlore/io/repair.py +0 -0
  40. {errlore-0.1.0 → errlore-0.1.2}/src/errlore/lessons/__init__.py +0 -0
  41. {errlore-0.1.0 → errlore-0.1.2}/src/errlore/lessons/store.py +0 -0
  42. {errlore-0.1.0 → errlore-0.1.2}/src/errlore/py.typed +0 -0
  43. {errlore-0.1.0 → errlore-0.1.2}/src/errlore/retrieval/__init__.py +0 -0
  44. {errlore-0.1.0 → errlore-0.1.2}/src/errlore/retrieval/backend.py +0 -0
  45. {errlore-0.1.0 → errlore-0.1.2}/src/errlore/sanitize.py +0 -0
  46. {errlore-0.1.0 → errlore-0.1.2}/src/errlore/trust/__init__.py +0 -0
  47. {errlore-0.1.0 → errlore-0.1.2}/src/errlore/trust/engine.py +0 -0
  48. {errlore-0.1.0 → errlore-0.1.2}/tests/conftest.py +0 -0
  49. {errlore-0.1.0 → errlore-0.1.2}/tests/test_errmem.py +0 -0
  50. {errlore-0.1.0 → errlore-0.1.2}/tests/test_facade.py +0 -0
  51. {errlore-0.1.0 → errlore-0.1.2}/tests/test_io.py +0 -0
  52. {errlore-0.1.0 → errlore-0.1.2}/tests/test_lessons.py +0 -0
  53. {errlore-0.1.0 → errlore-0.1.2}/tests/test_openwebui_integration.py +0 -0
  54. {errlore-0.1.0 → errlore-0.1.2}/tests/test_regressions.py +0 -0
  55. {errlore-0.1.0 → errlore-0.1.2}/tests/test_retrieval.py +0 -0
  56. {errlore-0.1.0 → errlore-0.1.2}/tests/test_trust.py +0 -0
@@ -10,7 +10,7 @@ jobs:
10
10
  runs-on: ubuntu-latest
11
11
  strategy:
12
12
  matrix:
13
- python-version: ["3.12", "3.13"]
13
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
14
14
  steps:
15
15
  - uses: actions/checkout@v4
16
16
  - uses: actions/setup-python@v5
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.2] - 2026-07-06
9
+
10
+ ### Added
11
+ - Error-reduction A/B benchmark with committed raw outputs: repeated
12
+ workspace-convention errors 46/48 -> 0/48 (100% reduction, McNemar
13
+ p=1.8e-09); capability-gap errors honestly unaffected.
14
+ - Claude Code hooks example (examples/claude-code/): PostToolUse failure
15
+ logging + SessionStart lessons briefing.
16
+ - 15-second terminal demo GIF (real library output).
17
+
18
+ ### Fixed
19
+ - `errlore.__version__` dunder reported 0.1.0 in the 0.1.1 wheel.
20
+
21
+ ## [0.1.1] - 2026-07-06
22
+
23
+ ### Fixed
24
+ - README benchmark table showed a stale MRR (0.290); the reproducible value
25
+ is 0.488 (`python benchmarks/bench_retrieval.py`).
26
+
27
+ ### Changed
28
+ - Python floor lowered to 3.10 (was 3.12): replaced `datetime.UTC` with
29
+ `timezone.utc`; CI now tests 3.10-3.13. This unblocks Open WebUI's
30
+ official image (Python 3.11).
31
+ - Added SECURITY.md and richer PyPI project URLs.
32
+
8
33
  ## [0.1.0] - 2026-07-05
9
34
 
10
35
  ### Added
@@ -1,23 +1,29 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: errlore
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Memory for AI agents that learns from failures: lessons, known-issues injection, and per-model trust — embedded, file-based, no server.
5
- Project-URL: Homepage, https://github.com/Ma4etaSS/errlore
5
+ Project-URL: Homepage, https://errlore.com
6
+ Project-URL: Repository, https://github.com/Ma4etaSS/errlore
7
+ Project-URL: Issues, https://github.com/Ma4etaSS/errlore/issues
8
+ Project-URL: Changelog, https://github.com/Ma4etaSS/errlore/blob/main/CHANGELOG.md
6
9
  Author: Ma4etaSS
7
10
  License-Expression: MIT
8
11
  License-File: LICENSE
9
12
  Keywords: agents,error-learning,lessons,llm,memory,trust
10
13
  Classifier: Development Status :: 3 - Alpha
11
14
  Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
12
17
  Classifier: Programming Language :: Python :: 3.12
13
18
  Classifier: Programming Language :: Python :: 3.13
14
19
  Classifier: Topic :: Software Development :: Libraries
15
- Requires-Python: >=3.12
20
+ Requires-Python: >=3.10
16
21
  Requires-Dist: filelock>=3.13
17
22
  Provides-Extra: dev
18
23
  Requires-Dist: fastembed<0.9,>=0.6; extra == 'dev'
19
24
  Requires-Dist: mypy>=1.11; extra == 'dev'
20
25
  Requires-Dist: numpy>=1.26; extra == 'dev'
26
+ Requires-Dist: pydantic>=2.0; extra == 'dev'
21
27
  Requires-Dist: pytest-cov>=5.0; extra == 'dev'
22
28
  Requires-Dist: pytest>=8.0; extra == 'dev'
23
29
  Requires-Dist: ruff>=0.6; extra == 'dev'
@@ -30,8 +36,12 @@ Description-Content-Type: text/markdown
30
36
 
31
37
  **Memory for AI agents that learns from failures.**
32
38
 
39
+ *Stop the second mistake, not just the first.*
40
+
41
+ ![errlore demo: monday failure becomes a lesson, tuesday's prompt gets it injected](https://errlore.com/demo.gif)
42
+
33
43
  [![CI](https://github.com/Ma4etaSS/errlore/actions/workflows/ci.yml/badge.svg)](https://github.com/Ma4etaSS/errlore/actions/workflows/ci.yml)
34
- [![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue)](https://python.org)
44
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://python.org)
35
45
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
36
46
 
37
47
  Extracted from a 324K LOC production multi-LLM orchestration system, keeping the one part that demonstrably worked: the error-memory loop that made agents stop repeating mistakes.
@@ -141,7 +151,7 @@ Tested on 40 lessons with adversarially paraphrased queries
141
151
  | recall@1 | 0.000 | 0.375 |
142
152
  | recall@3 | 0.000 | 0.575 |
143
153
  | recall@5 | 0.000 | 0.675 |
144
- | MRR | 0.000 | 0.290 |
154
+ | MRR | 0.000 | 0.488 |
145
155
 
146
156
  The gold set is intentionally adversarial (queries share few literal words
147
157
  with the lesson text), which is why word-overlap scores zero. On natural
@@ -2,8 +2,12 @@
2
2
 
3
3
  **Memory for AI agents that learns from failures.**
4
4
 
5
+ *Stop the second mistake, not just the first.*
6
+
7
+ ![errlore demo: monday failure becomes a lesson, tuesday's prompt gets it injected](https://errlore.com/demo.gif)
8
+
5
9
  [![CI](https://github.com/Ma4etaSS/errlore/actions/workflows/ci.yml/badge.svg)](https://github.com/Ma4etaSS/errlore/actions/workflows/ci.yml)
6
- [![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue)](https://python.org)
10
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://python.org)
7
11
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
8
12
 
9
13
  Extracted from a 324K LOC production multi-LLM orchestration system, keeping the one part that demonstrably worked: the error-memory loop that made agents stop repeating mistakes.
@@ -113,7 +117,7 @@ Tested on 40 lessons with adversarially paraphrased queries
113
117
  | recall@1 | 0.000 | 0.375 |
114
118
  | recall@3 | 0.000 | 0.575 |
115
119
  | recall@5 | 0.000 | 0.675 |
116
- | MRR | 0.000 | 0.290 |
120
+ | MRR | 0.000 | 0.488 |
117
121
 
118
122
  The gold set is intentionally adversarial (queries share few literal words
119
123
  with the lesson text), which is why word-overlap scores zero. On natural
@@ -0,0 +1,15 @@
1
+ # Security Policy
2
+
3
+ ## Supported versions
4
+ Latest 0.x release.
5
+
6
+ ## Model
7
+ errlore is a local, embedded library: no network calls, no telemetry, no
8
+ server. All data lives in plain files under the data_dir you choose —
9
+ protect that directory with normal filesystem permissions. Lesson text is
10
+ injected into prompts verbatim after sanitization; treat lessons from
11
+ untrusted sources as untrusted prompt content.
12
+
13
+ ## Reporting
14
+ Email postoluk.m2@gmail.com. No bounty program; fixes are prioritized and
15
+ credited in the changelog.
@@ -0,0 +1,480 @@
1
+ #!/usr/bin/env python3
2
+ """Error-reduction A/B benchmark: does errlore lesson injection reduce
3
+ repeated mistakes?
4
+
5
+ Task families were selected via a small difficulty probe so the target
6
+ model has a NON-ZERO baseline error rate (a precondition for measuring
7
+ error reduction; a model that never errs has nothing to reduce).
8
+
9
+ Protocol (paired, deterministic validators, no LLM judges):
10
+
11
+ Pass 1 (seed): run SEED tasks with a plain prompt. Every failure is
12
+ logged into errlore; each failing *family* gets its pre-authored
13
+ corrective lesson via resolve(). Lessons are authored below, in this
14
+ file, BEFORE any pass-2 output is seen — mirroring the real workflow
15
+ where a human fixes a failure once.
16
+ Pass 2 (test): run TEST tasks (same families, different instances) twice:
17
+ arm A -- plain prompt (control)
18
+ arm B -- prompt + errlore inject_for() block
19
+ Same model, temperature 0, same order.
20
+
21
+ Metric: per-arm failure rate on TEST tasks + exact McNemar on the paired
22
+ outcomes. Raw model outputs are dumped to JSONL for independent audit.
23
+
24
+ Run:
25
+ CEREBRAS_API_KEY=... python benchmarks/bench_error_reduction.py
26
+ (options: --model, --families, --pilot)
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import json
33
+ import math
34
+ import os
35
+ import random
36
+ import sys
37
+ import tempfile
38
+ import time
39
+ from dataclasses import dataclass
40
+ from pathlib import Path
41
+
42
+ from openai import OpenAI
43
+
44
+ from errlore import AgentMemory
45
+
46
+ RNG_SEED = 20260706
47
+ SEED_PER_FAMILY = 6
48
+ TEST_PER_FAMILY = 12
49
+
50
+ MONTHS = ["January", "February", "March", "April", "May", "June", "July",
51
+ "August", "September", "October", "November", "December"]
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Task families. Each generates (prompt, expected) pairs with deterministic
55
+ # string validators. Lessons are the one-time human fix for that error class.
56
+ # ---------------------------------------------------------------------------
57
+
58
+
59
+ @dataclass
60
+ class Task:
61
+ family: str
62
+ prompt: str
63
+ expected: str
64
+
65
+
66
+ def fam_mult4(rng: random.Random, n: int) -> list[Task]:
67
+ tasks = []
68
+ for _ in range(n):
69
+ a, b = rng.randint(1000, 9999), rng.randint(1000, 9999)
70
+ tasks.append(Task(
71
+ "mult4",
72
+ f"Compute {a}*{b}. You may show working, but the LAST LINE of "
73
+ f"your reply must be exactly the integer result alone - no "
74
+ f"thousands separators, no punctuation.",
75
+ str(a * b),
76
+ ))
77
+ return tasks
78
+
79
+
80
+ _WORDS = ["kaleidoscope", "murmuration", "photosynthesis", "labyrinth",
81
+ "quintessential", "juxtaposition", "serendipity", "onomatopoeia",
82
+ "perpendicular", "hummingbird", "thunderstorm", "wheelbarrow",
83
+ "grasshopper", "lighthouse", "watermelon", "caterpillar"]
84
+
85
+
86
+ def fam_nth_char(rng: random.Random, n: int) -> list[Task]:
87
+ tasks = []
88
+ for _ in range(n):
89
+ s = rng.choice(_WORDS) + rng.choice("-._&") + rng.choice(_WORDS)
90
+ pos = rng.randint(9, len(s) - 3)
91
+ tasks.append(Task(
92
+ "nth_char",
93
+ f'What is character number {pos} (1-indexed, counting every '
94
+ f'character including punctuation) of the string "{s}"? '
95
+ f"You may show working, but the LAST LINE of your reply must be "
96
+ f"exactly that single character alone.",
97
+ s[pos - 1],
98
+ ))
99
+ return tasks
100
+
101
+
102
+ def fam_letter_sent(rng: random.Random, n: int) -> list[Task]:
103
+ pool = ["the", "quick", "brown", "river", "jumps", "over", "seven",
104
+ "lazy", "green", "turtles", "under", "bright", "summer",
105
+ "evening", "skies", "while", "children", "gather", "berries",
106
+ "near", "wooden", "bridges", "watching", "herons", "gliding"]
107
+ tasks = []
108
+ for _ in range(n):
109
+ words = [rng.choice(pool) for _ in range(rng.randint(11, 14))]
110
+ sent = " ".join(words)
111
+ ch = rng.choice("erns")
112
+ tasks.append(Task(
113
+ "letter_sent",
114
+ f'How many times does the letter "{ch}" appear in this sentence: '
115
+ f'"{sent}"? You may show working, but the LAST LINE of your '
116
+ f'reply must be exactly the integer alone.',
117
+ str(sent.count(ch)),
118
+ ))
119
+ return tasks
120
+
121
+
122
+ def fam_reverse(rng: random.Random, n: int) -> list[Task]:
123
+ tasks = []
124
+ for _ in range(n):
125
+ s = rng.choice(_WORDS) + rng.choice("-._") + rng.choice(_WORDS)
126
+ tasks.append(Task(
127
+ "reverse",
128
+ f'Reverse the string "{s}" exactly, character by character. '
129
+ f"You may show working, but the LAST LINE of your reply must be "
130
+ f"exactly the reversed string alone.",
131
+ s[::-1],
132
+ ))
133
+ return tasks
134
+
135
+
136
+
137
+
138
+ # --- Knowledge-gap families: the failure is a missing WORKSPACE CONVENTION,
139
+ # not a capability deficit. Without memory the model cannot know the rule;
140
+ # after one failure+fix the lesson supplies it. This mirrors the product's
141
+ # core claim (schema contracts, environment quirks, domain conventions).
142
+
143
+
144
+ def fam_log_ts(rng: random.Random, n: int) -> list[Task]:
145
+ tasks = []
146
+ for _ in range(n):
147
+ y, mo, d = rng.randint(2023, 2026), rng.randint(1, 12), rng.randint(1, 28)
148
+ h, mi = rng.randint(0, 23), rng.randint(0, 59)
149
+ iso = f"{y}-{mo:02d}-{d:02d}T{h:02d}:{mi:02d}:00Z"
150
+ tasks.append(Task(
151
+ "log_ts",
152
+ f"Convert this timestamp to our internal log format: {iso}. "
153
+ f"The LAST LINE of your reply must be the formatted timestamp alone.",
154
+ f"{d:02d}|{mo:02d}|{y}@{h:02d}:{mi:02d}",
155
+ ))
156
+ return tasks
157
+
158
+
159
+ def fam_id_norm(rng: random.Random, n: int) -> list[Task]:
160
+ first = ["Anna", "Boris", "Clara", "Dmitri", "Elena", "Felix", "Greta", "Hugo"]
161
+ last = ["Koval", "Smith", "Weber", "Rossi", "Novak", "Braun", "Lang", "Mora"]
162
+ tasks = []
163
+ for _ in range(n):
164
+ raw = f"{rng.choice(first)}-{rng.choice(last)}-{rng.randint(10, 99)}"
165
+ tasks.append(Task(
166
+ "id_norm",
167
+ f'Normalize this raw user id for our system: "{raw}". '
168
+ f"The LAST LINE of your reply must be the normalized id alone.",
169
+ "u_" + raw.lower().replace("-", ""),
170
+ ))
171
+ return tasks
172
+
173
+
174
+ def fam_round_rule(rng: random.Random, n: int) -> list[Task]:
175
+ tasks = []
176
+ for _ in range(n):
177
+ cents = rng.randint(1000, 99999)
178
+ third = rng.choice([3, 6, 7, 9])
179
+ price = cents / 100 + third / 1000 # e.g. 123.456
180
+ expected = f"{int(price * 100) / 100:.2f}" # truncate = round DOWN
181
+ tasks.append(Task(
182
+ "round_rule",
183
+ f"Apply our finance rounding rule to {price:.3f} and give the "
184
+ f"amount with 2 decimals. The LAST LINE must be the number alone.",
185
+ expected,
186
+ ))
187
+ return tasks
188
+
189
+
190
+ def fam_csv_order(rng: random.Random, n: int) -> list[Task]:
191
+ names = ["Ivy", "Max", "Zoe", "Kai", "Lea", "Tom", "Ada", "Rex"]
192
+ tasks = []
193
+ for _ in range(n):
194
+ name = rng.choice(names)
195
+ uid = rng.randint(100, 999)
196
+ email = f"{name.lower()}{uid}@example.com"
197
+ fields = {"name": name, "id": str(uid), "email": email}
198
+ keys = list(fields)
199
+ rng.shuffle(keys)
200
+ jumbled = json.dumps({k: fields[k] for k in keys})
201
+ tasks.append(Task(
202
+ "csv_order",
203
+ f"Convert this record to a CSV row in our canonical column "
204
+ f"order: {jumbled}. The LAST LINE must be the CSV row alone.",
205
+ f"{email},{uid},{name}",
206
+ ))
207
+ return tasks
208
+
209
+ # capability-gap families (model skill limits) + knowledge-gap families
210
+ # (workspace conventions) — reported separately.
211
+ FAMILIES = {
212
+ "mult4": fam_mult4,
213
+ "nth_char": fam_nth_char,
214
+ "letter_sent": fam_letter_sent,
215
+ "reverse": fam_reverse,
216
+ "log_ts": fam_log_ts,
217
+ "id_norm": fam_id_norm,
218
+ "round_rule": fam_round_rule,
219
+ "csv_order": fam_csv_order,
220
+ }
221
+
222
+ KNOWLEDGE_GAP = {"log_ts", "id_norm", "round_rule", "csv_order"}
223
+
224
+ # Pre-authored corrective lessons (the "human fix", written before pass 2).
225
+ LESSONS = {
226
+ "mult4": "Never answer multi-digit multiplication from intuition. Break it into partial products and add them carefully. Verify: the last digit of the result must equal (last digit of a * last digit of b) mod 10. Then put the final integer ALONE on the last line - no commas, no words after it.",
227
+ "nth_char": "Write out the string with numbered positions (1,2,3,...), counting EVERY character including hyphens and punctuation, then pick exactly the requested position. Put that single character ALONE on the last line.",
228
+ "letter_sent": "Go word by word and count the target letter in each word, keeping a running total across ALL words. Then put the final integer ALONE on the last line.",
229
+ "reverse": "Build the reversal by writing characters from the END one at a time. Verify lengths match and the first output char equals the last input char. Then put the reversed string ALONE on the last line.",
230
+ "log_ts": "Our internal log timestamp format is DD|MM|YYYY@HH:mm (day first, pipe separators, @ before the 24h time, no seconds, no timezone). Example: 2024-03-05T14:07:00Z -> 05|03|2024@14:07.",
231
+ "id_norm": "Our user-id normalization rule: lowercase everything, remove all dashes, then add the prefix u_. Example: Anna-Koval-42 -> u_annakoval42.",
232
+ "round_rule": "Our finance rule ALWAYS truncates (rounds toward zero) to 2 decimals - never round half up or to even. Example: 123.456 -> 123.45, and 99.999 -> 99.99.",
233
+ "csv_order": "Our canonical CSV column order is: email,id,name (regardless of the order fields appear in the source record). No spaces after commas, no quotes.",
234
+ }
235
+
236
+ SYSTEM = "You are a precise assistant. Follow the required output format exactly."
237
+
238
+
239
+ # ---------------------------------------------------------------------------
240
+ # Validators
241
+ # ---------------------------------------------------------------------------
242
+
243
+
244
+ def validate(task: Task, output: str) -> bool:
245
+ out = output.strip()
246
+ if task.expected == "__JSON5__":
247
+ try:
248
+ if out.startswith("```"):
249
+ out = out.strip("`")
250
+ out = out[out.find("{"):out.rfind("}") + 1]
251
+ obj = json.loads(out)
252
+ except (json.JSONDecodeError, ValueError):
253
+ return False
254
+ fixes = obj.get("fixes")
255
+ return (
256
+ isinstance(obj, dict) and list(obj.keys()) == ["fixes"]
257
+ and isinstance(fixes, list) and len(fixes) == 5
258
+ and all(isinstance(x, str) and x.strip() for x in fixes)
259
+ and len(set(fixes)) == 5
260
+ )
261
+ # exact-match families: the contract is "last line = answer alone"
262
+ lines = [ln.strip() for ln in out.splitlines() if ln.strip()]
263
+ last = (lines[-1] if lines else "").rstrip(".").strip().strip('"')
264
+ return last == task.expected
265
+
266
+
267
+ # ---------------------------------------------------------------------------
268
+ # Model call
269
+ # ---------------------------------------------------------------------------
270
+
271
+
272
+ def make_client(backend: str) -> tuple[OpenAI, str]:
273
+ if backend == "cerebras":
274
+ key = os.environ.get("CEREBRAS_API_KEY")
275
+ if not key:
276
+ sys.exit("CEREBRAS_API_KEY is required")
277
+ return OpenAI(base_url="https://api.cerebras.ai/v1", api_key=key), "gemma-4-31b"
278
+ if backend == "anthropic":
279
+ # Uses the Anthropic SDK directly (no OpenAI-compatible endpoint).
280
+ key = os.environ.get("ANTHROPIC_API_KEY")
281
+ if not key:
282
+ sys.exit("ANTHROPIC_API_KEY is required")
283
+ return None, "claude-haiku-4-5" # client built lazily in ask()
284
+ if backend == "gemini":
285
+ key = os.environ.get("GEMINI_API_KEY")
286
+ if not key:
287
+ sys.exit("GEMINI_API_KEY is required")
288
+ return OpenAI(
289
+ base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
290
+ api_key=key,
291
+ ), "gemini-2.5-flash-lite"
292
+ sys.exit(f"unknown backend {backend}")
293
+
294
+
295
+ _anthropic_client = None
296
+
297
+
298
+ def _ask_anthropic(model: str, system: str, prompt: str) -> str:
299
+ global _anthropic_client
300
+ import anthropic
301
+
302
+ if _anthropic_client is None:
303
+ _anthropic_client = anthropic.Anthropic()
304
+ for attempt in range(6):
305
+ try:
306
+ resp = _anthropic_client.messages.create(
307
+ model=model,
308
+ max_tokens=500,
309
+ temperature=0,
310
+ system=system,
311
+ messages=[{"role": "user", "content": prompt}],
312
+ )
313
+ return next((b.text for b in resp.content if b.type == "text"), "")
314
+ except anthropic.RateLimitError:
315
+ if attempt == 5:
316
+ raise
317
+ time.sleep(31)
318
+ raise RuntimeError("unreachable")
319
+
320
+
321
+ def ask(client: OpenAI, model: str, prompt: str, extra_system: str = "") -> str:
322
+ from openai import RateLimitError
323
+
324
+ system = SYSTEM + (("\n\n" + extra_system) if extra_system else "")
325
+ if client is None: # anthropic backend
326
+ return _ask_anthropic(model, system, prompt)
327
+ for attempt in range(6):
328
+ try:
329
+ resp = client.chat.completions.create(
330
+ model=model,
331
+ temperature=0,
332
+ max_tokens=400,
333
+ messages=[{"role": "system", "content": system},
334
+ {"role": "user", "content": prompt}],
335
+ )
336
+ return resp.choices[0].message.content or ""
337
+ except RateLimitError:
338
+ if attempt == 5:
339
+ raise
340
+ time.sleep(31) # free-tier RPM window
341
+ raise RuntimeError("unreachable")
342
+
343
+
344
+ # ---------------------------------------------------------------------------
345
+ # Experiment
346
+ # ---------------------------------------------------------------------------
347
+
348
+
349
+ def mcnemar_exact_p(b: int, c: int) -> float:
350
+ """Two-sided exact McNemar on discordant pairs (binomial)."""
351
+ n = b + c
352
+ if n == 0:
353
+ return 1.0
354
+ k = min(b, c)
355
+ p = sum(math.comb(n, i) for i in range(0, k + 1)) / (2 ** n) * 2
356
+ return min(1.0, p)
357
+
358
+
359
+ def main() -> int:
360
+ ap = argparse.ArgumentParser()
361
+ ap.add_argument("--backend", default="gemini")
362
+ ap.add_argument("--pilot", action="store_true", help="2 families, 3 test each")
363
+ ap.add_argument("--out", default=None)
364
+ args = ap.parse_args()
365
+
366
+ client, model = make_client(args.backend)
367
+ rng = random.Random(RNG_SEED)
368
+
369
+ fams = dict(FAMILIES)
370
+ seed_n, test_n = SEED_PER_FAMILY, TEST_PER_FAMILY
371
+ if args.pilot:
372
+ fams = {k: fams[k] for k in ("mult4", "nth_char")}
373
+ seed_n, test_n = 3, 3
374
+
375
+ seed_tasks: list[Task] = []
376
+ test_tasks: list[Task] = []
377
+ for _name, gen in fams.items():
378
+ all_t = gen(rng, seed_n + test_n)
379
+ seed_tasks += all_t[:seed_n]
380
+ test_tasks += all_t[seed_n:]
381
+
382
+ workdir = Path(tempfile.mkdtemp(prefix="errlore_ab_"))
383
+ mem = AgentMemory(workdir / "memory")
384
+ raw_log = open(workdir / "raw_outputs.jsonl", "w")
385
+
386
+ def record(phase: str, arm: str, task: Task, output: str, ok: bool) -> None:
387
+ raw_log.write(json.dumps({
388
+ "phase": phase, "arm": arm, "family": task.family,
389
+ "prompt": task.prompt, "expected": task.expected,
390
+ "output": output, "ok": ok,
391
+ }, ensure_ascii=False) + "\n")
392
+
393
+ # ---- Pass 1: seed (plain prompts; failures become lessons) ----
394
+ fam_fail: dict[str, int] = {}
395
+ print(f"[pass1] {len(seed_tasks)} seed tasks, model={model}")
396
+ for t in seed_tasks:
397
+ out = ask(client, model, t.prompt)
398
+ ok = validate(t, out)
399
+ record("seed", "plain", t, out, ok)
400
+ if not ok:
401
+ fam_fail[t.family] = fam_fail.get(t.family, 0) + 1
402
+ err_id = mem.log_error(model, t.family, f"WrongAnswer: {out[:120]}")
403
+ mem.resolve(err_id, "authored corrective lesson", lesson=LESSONS[t.family])
404
+ print(f"[pass1] failures by family: {fam_fail or 'none'}")
405
+ active = set(fam_fail)
406
+ if not active:
407
+ print("[pass1] model aced the seed set — no lessons to test; "
408
+ "harden the task families before drawing conclusions.")
409
+ return 1
410
+
411
+ # ---- Pass 2: paired test ----
412
+ results = [] # (family, ok_A, ok_B)
413
+ print(f"[pass2] {len(test_tasks)} test tasks x 2 arms")
414
+ for t in test_tasks:
415
+ out_a = ask(client, model, t.prompt)
416
+ ok_a = validate(t, out_a)
417
+ record("test", "A_plain", t, out_a, ok_a)
418
+
419
+ inj = mem.inject_for(t.prompt, model=model, task_type=t.family)
420
+ out_b = ask(client, model, t.prompt, extra_system=inj.text)
421
+ ok_b = validate(t, out_b)
422
+ record("test", "B_errlore", t, out_b, ok_b)
423
+ mem.report_outcome(inj, ok_b)
424
+ results.append((t.family, ok_a, ok_b))
425
+
426
+ raw_log.close()
427
+
428
+ # ---- Report ----
429
+ n = len(results)
430
+ fail_a = sum(1 for _, a, _b in results if not a)
431
+ fail_b = sum(1 for _, _a, b in results if not b)
432
+ b_disc = sum(1 for _, a, b in results if a and not b) # B fails where A passed
433
+ c_disc = sum(1 for _, a, b in results if not a and b) # B fixes A's failure
434
+ p = mcnemar_exact_p(b_disc, c_disc)
435
+
436
+ lines = [
437
+ f"# errlore error-reduction A/B — model {model}",
438
+ "",
439
+ f"tasks (test): {n} | families active (had seed failures): {sorted(active)}",
440
+ "",
441
+ "| arm | failures | fail rate |",
442
+ "|---|---|---|",
443
+ f"| A plain | {fail_a}/{n} | {fail_a/n:.1%} |",
444
+ f"| B errlore | {fail_b}/{n} | {fail_b/n:.1%} |",
445
+ "",
446
+ f"discordant pairs: errlore fixed {c_disc}, errlore broke {b_disc}",
447
+ f"exact McNemar p = {p:.4g}",
448
+ ]
449
+ if fail_a:
450
+ lines.append(f"repeat-error reduction: {(fail_a - fail_b) / fail_a:.1%}")
451
+ for label, group in (("KNOWLEDGE-GAP (workspace conventions)",
452
+ [r for r in results if r[0] in KNOWLEDGE_GAP]),
453
+ ("CAPABILITY-GAP (model skill limits)",
454
+ [r for r in results if r[0] not in KNOWLEDGE_GAP])):
455
+ if not group:
456
+ continue
457
+ gn = len(group)
458
+ ga = sum(1 for _, a, _b in group if not a)
459
+ gb = sum(1 for _, _a, b in group if not b)
460
+ red = f" | reduction {(ga - gb) / ga:.0%}" if ga else ""
461
+ lines += ["", f"### {label}: A {ga}/{gn} -> B {gb}/{gn}{red}"]
462
+
463
+ lines += ["", "per-family (fail A -> fail B):"]
464
+ for fam in sorted(fams):
465
+ fa = sum(1 for f, a, _ in results if f == fam and not a)
466
+ fb = sum(1 for f, _, b in results if f == fam and not b)
467
+ marker = " *lesson active*" if fam in active else ""
468
+ lines.append(f"- {fam}: {fa} -> {fb}{marker}")
469
+ lines += ["", f"raw outputs: {workdir}/raw_outputs.jsonl",
470
+ f"errlore stats: {mem.stats()}"]
471
+ report = "\n".join(lines)
472
+ print("\n" + report)
473
+ out_path = Path(args.out) if args.out else workdir / "report.md"
474
+ out_path.write_text(report)
475
+ print(f"\n[saved: {out_path}]")
476
+ return 0
477
+
478
+
479
+ if __name__ == "__main__":
480
+ sys.exit(main())
@@ -0,0 +1,29 @@
1
+ # errlore error-reduction A/B — model claude-haiku-4-5
2
+
3
+ tasks (test): 96 | families active (had seed failures): ['csv_order', 'id_norm', 'letter_sent', 'log_ts', 'reverse', 'round_rule']
4
+
5
+ | arm | failures | fail rate |
6
+ |---|---|---|
7
+ | A plain | 63/96 | 65.6% |
8
+ | B errlore | 20/96 | 20.8% |
9
+
10
+ discordant pairs: errlore fixed 49, errlore broke 6
11
+ exact McNemar p = 1.823e-09
12
+ repeat-error reduction: 68.3%
13
+
14
+ ### KNOWLEDGE-GAP (workspace conventions): A 46/48 -> B 0/48 | reduction 100%
15
+
16
+ ### CAPABILITY-GAP (model skill limits): A 17/48 -> B 20/48 | reduction -18%
17
+
18
+ per-family (fail A -> fail B):
19
+ - csv_order: 12 -> 0 *lesson active*
20
+ - id_norm: 12 -> 0 *lesson active*
21
+ - letter_sent: 6 -> 8 *lesson active*
22
+ - log_ts: 12 -> 0 *lesson active*
23
+ - mult4: 2 -> 1
24
+ - nth_char: 0 -> 0
25
+ - reverse: 9 -> 11 *lesson active*
26
+ - round_rule: 10 -> 0 *lesson active*
27
+
28
+ raw outputs: /tmp/errlore_ab_4iljcym1/raw_outputs.jsonl
29
+ errlore stats: {'errors_total': 29, 'errors_resolved': 29, 'errors_unresolved': 0, 'lessons_total': 23, 'lessons_applied': 14, 'pending_injections': 0, 'trust': {'claude-haiku-4-5': 0.92}}
@@ -0,0 +1,23 @@
1
+ # errlore + Claude Code
2
+
3
+ Give your coding agent a memory of its own failures across sessions:
4
+
5
+ - **PostToolUse hook** — every failed Bash command is logged into errlore.
6
+ Resolve the ones you fixed (`mem.resolve(err_id, ..., lesson=...)`) or use
7
+ `mem.add_lesson()` to capture takeaways directly.
8
+ - **SessionStart hook** — each new session begins with a briefing block of
9
+ relevant lessons and per-tool KNOWN ISSUES, printed into the context.
10
+
11
+ ## Setup
12
+
13
+ 1. `pip install errlore`
14
+ 2. Copy both scripts somewhere stable, adjust paths in
15
+ `settings.json.example`, and merge it into your `.claude/settings.json`
16
+ (project) or `~/.claude/settings.json` (global).
17
+ 3. Optional: `export ERRLORE_DATA=...` to choose where the memory lives
18
+ (defaults to `~/.errlore/claude-code`).
19
+
20
+ Notes: hook event field names can differ between Claude Code versions —
21
+ the PostToolUse script reads them defensively and never breaks the agent
22
+ loop (exit 0 on anything unexpected). Check `claude --help` / the hooks
23
+ docs for your version if events don't arrive.