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.
- {errlore-0.1.0 → errlore-0.1.2}/.github/workflows/ci.yml +1 -1
- {errlore-0.1.0 → errlore-0.1.2}/CHANGELOG.md +25 -0
- {errlore-0.1.0 → errlore-0.1.2}/PKG-INFO +15 -5
- {errlore-0.1.0 → errlore-0.1.2}/README.md +6 -2
- errlore-0.1.2/SECURITY.md +15 -0
- errlore-0.1.2/benchmarks/bench_error_reduction.py +480 -0
- errlore-0.1.2/benchmarks/results/error_reduction/report.md +29 -0
- errlore-0.1.2/examples/claude-code/README.md +23 -0
- errlore-0.1.2/examples/claude-code/errlore_posttooluse.py +44 -0
- errlore-0.1.2/examples/claude-code/errlore_sessionstart.py +26 -0
- errlore-0.1.2/examples/claude-code/settings.json.example +25 -0
- {errlore-0.1.0 → errlore-0.1.2}/integrations/openwebui/README.md +2 -3
- {errlore-0.1.0 → errlore-0.1.2}/pyproject.toml +13 -5
- errlore-0.1.2/site/demo.gif +0 -0
- errlore-0.1.2/site/demo_script.py +91 -0
- errlore-0.1.2/site/index.html +262 -0
- errlore-0.1.2/site/llms.txt +31 -0
- errlore-0.1.2/site/og.png +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/src/errlore/__init__.py +1 -1
- {errlore-0.1.0 → errlore-0.1.2}/src/errlore/errmem/tracker.py +2 -2
- {errlore-0.1.0 → errlore-0.1.2}/src/errlore/lessons/models.py +2 -2
- {errlore-0.1.0 → errlore-0.1.2}/src/errlore/retrieval/index.py +8 -6
- {errlore-0.1.0 → errlore-0.1.2}/.gitignore +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/LICENSE +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/benchmarks/bench_retrieval.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/examples/anthropic_agent.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/examples/langchain_agent.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/examples/openai_agent.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/integrations/openwebui/errlore_feedback_action.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/integrations/openwebui/errlore_memory_filter.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/src/errlore/errmem/__init__.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/src/errlore/errmem/classifier.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/src/errlore/errmem/injector.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/src/errlore/errmem/patterns.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/src/errlore/facade.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/src/errlore/io/__init__.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/src/errlore/io/jsonl_index.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/src/errlore/io/jsonl_writer.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/src/errlore/io/repair.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/src/errlore/lessons/__init__.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/src/errlore/lessons/store.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/src/errlore/py.typed +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/src/errlore/retrieval/__init__.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/src/errlore/retrieval/backend.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/src/errlore/sanitize.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/src/errlore/trust/__init__.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/src/errlore/trust/engine.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/tests/conftest.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/tests/test_errmem.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/tests/test_facade.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/tests/test_io.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/tests/test_lessons.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/tests/test_openwebui_integration.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/tests/test_regressions.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/tests/test_retrieval.py +0 -0
- {errlore-0.1.0 → errlore-0.1.2}/tests/test_trust.py +0 -0
|
@@ -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.
|
|
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://
|
|
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.
|
|
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
|
+

|
|
42
|
+
|
|
33
43
|
[](https://github.com/Ma4etaSS/errlore/actions/workflows/ci.yml)
|
|
34
|
-
[](https://python.org)
|
|
35
45
|
[](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.
|
|
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
|
+

|
|
8
|
+
|
|
5
9
|
[](https://github.com/Ma4etaSS/errlore/actions/workflows/ci.yml)
|
|
6
|
-
[](https://python.org)
|
|
7
11
|
[](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.
|
|
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.
|