sdm-learn 0.1.0.dev0__py3-none-any.whl

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.
sdm/formats.py ADDED
@@ -0,0 +1,1182 @@
1
+ """How the hypothesis is written down, and how a revision is judged.
2
+
3
+ This is the first of the four components, and the only one with real
4
+ machinery. A skill format owns three things: where the learned hypothesis is
5
+ stored, how the model perceives it, and what makes a proposed revision legal.
6
+
7
+ There are three mechanisms, and every ``format.md`` section picks one:
8
+
9
+ edits numbered rules with counters, revised by one bounded diff
10
+ document one text document, replaced whole or kept
11
+ signal a source the model never re-reads, only its rendering
12
+
13
+ ``edits`` and ``document`` are pure configuration, so a new text-based format
14
+ is a new ``format.md`` section and no Python. ``signal`` needs a codec class,
15
+ which lives in ``signals.py`` and is imported only on demand.
16
+
17
+ The split of responsibility is deliberate: size knobs come from ``format.md``
18
+ so a user can retune an experiment, but the safety rules are here in Python.
19
+ The harness being able to refuse an illegal document is a premise of the
20
+ method, not a preference.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import io
27
+ import json
28
+ import os
29
+ import re
30
+ import shutil
31
+ import subprocess
32
+ import tempfile
33
+ from html.parser import HTMLParser
34
+ from pathlib import Path
35
+
36
+ from .data import _inputs
37
+ from .gateway import SetupNeeded
38
+
39
+
40
+ CONDITION_WORDS = 45
41
+
42
+ PREDICTION_RE = re.compile(
43
+ r"^P(\d{1,5})\s*\|\s*([^|]+?)\s*\|\s*([^|]*)\|\s*(.+)$", re.IGNORECASE)
44
+ PLAIN_PREDICTION_RE = re.compile(
45
+ r"^P(\d{1,5})\s*\|\s*([^|]+?)\s*\|\s*(.+)$", re.IGNORECASE)
46
+ GATE_LINE_RE = re.compile(r"^P(\d{1,5})\s*\|\s*([^|]+?)(?:\s*\|.*)?$")
47
+ EDITS_RE = re.compile(r"<edits>\s*(.*?)\s*</edits>", re.DOTALL | re.IGNORECASE)
48
+ DOCUMENT_RE = re.compile(
49
+ r'<predictions\s+document=["\']?([AB])["\']?\s*>\s*(.*?)\s*</predictions>',
50
+ re.IGNORECASE | re.DOTALL)
51
+
52
+ EDIT_GRAMMAR = """ADD | class | predicate for an unseen input
53
+ ADD | general | reusable reading procedure or cross-class exclusion
54
+ REVISE | R001 | complete replacement predicate
55
+ SUPPORT | R001
56
+ DELETE | R001
57
+ NOOP"""
58
+
59
+
60
+ def _base_state(config: dict) -> dict:
61
+ return {"version": 1, "config": config, "train": [], "tests": {},
62
+ "request_audit": []}
63
+
64
+
65
+ def new_state(config: dict) -> dict:
66
+ """A fresh rulebook state."""
67
+ return {**_base_state(config), "next_rule_id": 1, "rules": []}
68
+
69
+
70
+ def _copy(state: dict) -> dict:
71
+ return json.loads(json.dumps(state))
72
+
73
+
74
+ def _rules_by_id(state: dict) -> dict[str, dict]:
75
+ return {rule["id"]: rule for rule in state.get("rules", [])}
76
+
77
+
78
+ def _dedupe(errors: list[str], keep: int = 8) -> list[str]:
79
+ seen: list[str] = []
80
+ for error in errors:
81
+ if error not in seen:
82
+ seen.append(error)
83
+ return seen[:keep]
84
+
85
+
86
+ def render_rulebook(state: dict) -> str:
87
+ """Render rulebook ``state`` as the Markdown document the model reads."""
88
+ config = state.get("config", {})
89
+ seen = len(state["train"])
90
+ correct = sum(int(row["correct"]) for row in state["train"])
91
+ rules = state.get("rules", [])
92
+ lines = [
93
+ f'# {config.get("title", "Rulebook")}',
94
+ "",
95
+ f"Seen: {seen} example(s). Prediction record: {correct}/{seen}.",
96
+ "",
97
+ "This complete document is the learned hypothesis. Rules must "
98
+ "describe only reusable evidence available in a new unlabeled input.",
99
+ "",
100
+ "## Reading procedure",
101
+ ]
102
+
103
+ def entry(rule):
104
+ return (f'{rule["id"]} [support={rule["support"]} used={rule["used"]} '
105
+ f'hit={rule["hit"]}] {rule["condition"]}')
106
+
107
+ general = [rule for rule in rules if rule["class"] == "general"]
108
+ if general:
109
+ lines += [f"{number}. {entry(rule)}"
110
+ for number, rule in enumerate(general, 1)]
111
+ else:
112
+ lines.append("No learned cross-class procedure yet.")
113
+
114
+ lines += ["", "## Per-class signatures"]
115
+ for label in config.get("classes", ()):
116
+ selected = [rule for rule in rules if rule["class"] == label]
117
+ lines += ["", f"### {label}"]
118
+ lines += ([f"- {entry(rule)}" for rule in selected] or
119
+ ["No reliable signature yet."])
120
+ return "\n".join(lines) + "\n"
121
+
122
+
123
+ def render(state: dict) -> str:
124
+ """Render any skill state as its source document.
125
+
126
+ Document formats keep their source in ``state["doc"]``; the rulebook is
127
+ rendered from its rules, so neither needs ``format.md`` to be readable.
128
+ """
129
+ if state.get("config", {}).get("format", "markdown") is None:
130
+ return ""
131
+ if "doc" in state:
132
+ return state["doc"]
133
+ return render_rulebook(state)
134
+
135
+
136
+ def parse_edits(text: str):
137
+ """Split an ``<edits>`` block into operation tuples plus syntax errors."""
138
+ match = EDITS_RE.search(text)
139
+ if not match:
140
+ return None, ["missing <edits> block"]
141
+ edits, errors = [], []
142
+ for raw in match.group(1).splitlines():
143
+ line = raw.strip().lstrip("- ").strip()
144
+ if not line or line.upper() == "NOOP":
145
+ continue
146
+ parts = [part.strip() for part in line.split("|")]
147
+ operation = parts[0].upper()
148
+ if operation == "ADD" and len(parts) == 3:
149
+ edits.append((operation, parts[1].lower(), parts[2]))
150
+ elif operation == "REVISE" and len(parts) == 3:
151
+ edits.append((operation, parts[1].upper(), parts[2]))
152
+ elif operation in {"DELETE", "SUPPORT"} and len(parts) == 2:
153
+ edits.append((operation, parts[1].upper()))
154
+ else:
155
+ errors.append(f"malformed edit: {line!r}")
156
+ return edits, errors
157
+
158
+
159
+ def clean_condition(condition: str, label: str) -> str:
160
+ """Strip the ``IF ...`` and ``... then predict X`` wrappers models add."""
161
+ condition = re.sub(r"^IF\s+", "", condition.strip(), flags=re.I)
162
+ condition = re.sub(
163
+ rf"\s*,?\s*(?:THEN\s+)?(?:predict|classify as)\s+{re.escape(label)}[.!]?$",
164
+ "", condition, flags=re.I)
165
+ return condition.strip()
166
+
167
+
168
+ def apply_edits(state: dict, edits: list[tuple], max_words: int,
169
+ max_rules: int) -> list[str]:
170
+ """Validate every edit against a copy, then commit them all or none."""
171
+ valid = {label.lower(): label
172
+ for label in state.get("config", {}).get("classes", ())}
173
+ valid["general"] = "general"
174
+ candidate = _copy(state)
175
+ rules = _rules_by_id(candidate)
176
+ errors: list[str] = []
177
+ touched: set[str] = set()
178
+
179
+ for edit in edits:
180
+ operation = edit[0]
181
+ if operation == "ADD":
182
+ _, raw_label, raw_condition = edit
183
+ label = valid.get(str(raw_label).strip().lower())
184
+ if label is None:
185
+ errors.append(f"ADD has invalid class {raw_label!r}")
186
+ continue
187
+ condition = clean_condition(raw_condition, label)
188
+ if not condition:
189
+ errors.append("ADD has an empty condition")
190
+ elif len(condition.split()) > CONDITION_WORDS:
191
+ errors.append(f"ADD condition exceeds {CONDITION_WORDS} words")
192
+ else:
193
+ rule_id = f'R{candidate["next_rule_id"]:03d}'
194
+ candidate["next_rule_id"] += 1
195
+ rule = {"id": rule_id, "class": label, "condition": condition,
196
+ "support": 1, "used": 0, "hit": 0}
197
+ candidate["rules"].append(rule)
198
+ rules[rule_id] = rule
199
+ continue
200
+
201
+ rule_id = edit[1]
202
+ if rule_id not in rules:
203
+ errors.append(f"{operation} references unknown rule {rule_id}")
204
+ elif rule_id in touched:
205
+ errors.append(f"multiple edits target {rule_id}")
206
+ else:
207
+ touched.add(rule_id)
208
+ if operation == "DELETE":
209
+ candidate["rules"] = [rule for rule in candidate["rules"]
210
+ if rule["id"] != rule_id]
211
+ rules.pop(rule_id)
212
+ elif operation == "SUPPORT":
213
+ rules[rule_id]["support"] += 1
214
+ elif operation == "REVISE":
215
+ condition = clean_condition(edit[2], rules[rule_id]["class"])
216
+ if not condition:
217
+ errors.append(f"REVISE {rule_id} has an empty condition")
218
+ elif len(condition.split()) > CONDITION_WORDS:
219
+ errors.append(f"REVISE {rule_id} condition exceeds "
220
+ f"{CONDITION_WORDS} words")
221
+ else:
222
+ rules[rule_id]["condition"] = condition
223
+ rules[rule_id]["support"] += 1
224
+
225
+ if len(candidate["rules"]) > max_rules:
226
+ errors.append(
227
+ f'rule count {len(candidate["rules"])} exceeds {max_rules}')
228
+ words = len(render_rulebook(candidate).split())
229
+ if words > max_words:
230
+ errors.append(f"skill has {words} words; hard limit is {max_words}")
231
+ if errors:
232
+ return errors
233
+ state.clear()
234
+ state.update(candidate)
235
+ return []
236
+
237
+
238
+ def _clean_line(raw: str) -> str:
239
+ """One response line with the decoration models add stripped off."""
240
+ line = raw.strip().strip("|").strip().replace("**", "").replace("`", "")
241
+ return re.sub(r"^[-*]\s*", "", line)
242
+
243
+
244
+ def parse_predictions(raw: str, count: int, classes,
245
+ cites_rules: bool = True) -> tuple[dict, list[str]]:
246
+ """Read one ``<predictions>`` block into position-indexed predictions."""
247
+ canonical = {str(label).lower(): str(label) for label in classes}
248
+ pattern = PREDICTION_RE if cites_rules else PLAIN_PREDICTION_RE
249
+ parsed: dict[int, dict] = {}
250
+ errors: list[str] = []
251
+ for raw_line in raw.splitlines():
252
+ match = pattern.match(_clean_line(raw_line))
253
+ if not match:
254
+ continue
255
+ groups = match.groups()
256
+ if cites_rules:
257
+ number, label, ids, evidence = groups
258
+ else:
259
+ number, label, evidence = groups
260
+ ids = ""
261
+ position = int(number)
262
+ label = label.strip().lower()
263
+ if not 1 <= position <= count:
264
+ errors.append(f"out-of-range P{position:02d}")
265
+ elif canonical and label not in canonical:
266
+ errors.append(f"invalid class {label!r} for P{position:02d}")
267
+ elif position in parsed:
268
+ errors.append(f"duplicate P{position:02d}")
269
+ else:
270
+ parsed[position] = {
271
+ "pred": canonical.get(label, label),
272
+ "rules_used": re.findall(r"R\d+", ids.upper()),
273
+ "evidence": evidence.strip()[:500],
274
+ }
275
+ missing = [position for position in range(1, count + 1)
276
+ if position not in parsed]
277
+ if missing:
278
+ errors.append(f"missing {missing}")
279
+ return parsed, errors
280
+
281
+
282
+ def credit(state: dict, predictions: list[dict], labels: list[str]) -> None:
283
+ """Attribute one batch outcome to every rule the predictor cited."""
284
+ rules = _rules_by_id(state)
285
+ for prediction, label in zip(predictions, labels):
286
+ for rule_id in set(prediction["rules_used"]):
287
+ rule = rules.get(rule_id)
288
+ if rule is not None:
289
+ rule["used"] += 1
290
+ rule["hit"] += int(prediction["pred"] == label)
291
+
292
+
293
+ def record(state: dict, kind: str, **values) -> None:
294
+ """Append one billed model request to the run's cost ledger."""
295
+ state["request_audit"].append(
296
+ {"request": len(state["request_audit"]) + 1, "kind": kind, **values})
297
+
298
+
299
+ # --------------------------------------------------------------------------
300
+
301
+ # Everything a static, self-contained document never needs. Scripts and frames
302
+ # could change what the graders see; external references would make the
303
+ # document depend on the network instead of on what was learned.
304
+ FORBIDDEN_TAGS = {
305
+ "script", "iframe", "object", "embed", "link", "base", "form", "applet",
306
+ "frame", "frameset", "audio", "video", "source",
307
+ }
308
+ # Containers whose truncation or misnesting silently swallows later content.
309
+ BALANCED_TAGS = {"html", "body", "svg", "table", "div"}
310
+
311
+
312
+ class _Auditor(HTMLParser):
313
+ """One pass over the document: safety, nesting, and the visible words."""
314
+
315
+ def __init__(self):
316
+ super().__init__(convert_charrefs=True)
317
+ self.errors: list[str] = []
318
+ self.words = 0
319
+ self.stack: list[str] = []
320
+ self._invisible = 0
321
+
322
+ def handle_starttag(self, tag, attrs):
323
+ self._check(tag, attrs)
324
+ if tag in BALANCED_TAGS:
325
+ self.stack.append(tag)
326
+ if tag == "style":
327
+ self._invisible += 1
328
+
329
+ def handle_startendtag(self, tag, attrs):
330
+ self._check(tag, attrs)
331
+
332
+ def handle_endtag(self, tag):
333
+ if tag == "style" and self._invisible:
334
+ self._invisible -= 1
335
+ if tag in BALANCED_TAGS:
336
+ if self.stack and self.stack[-1] == tag:
337
+ self.stack.pop()
338
+ else:
339
+ self.errors.append(f"misnested or unopened </{tag}>")
340
+
341
+ def handle_data(self, data):
342
+ if not self._invisible:
343
+ self.words += len(data.split())
344
+
345
+ def _check(self, tag, attrs):
346
+ if tag in FORBIDDEN_TAGS:
347
+ self.errors.append(f"forbidden tag <{tag}>")
348
+ for name, value in attrs:
349
+ name = name.lower()
350
+ if name.startswith("on"):
351
+ self.errors.append(
352
+ f"forbidden event attribute {name!r} on <{tag}>")
353
+ if name == "xmlns" or name.startswith("xmlns:"):
354
+ # A namespace declaration identifies a vocabulary; nothing is
355
+ # fetched from it, so the standard SVG xmlns is fine.
356
+ continue
357
+ lowered = (value or "").strip().lower()
358
+ if lowered.startswith(("http://", "https://", "//",
359
+ "javascript:")):
360
+ self.errors.append(
361
+ f"<{tag} {name}=...> references outside the document; "
362
+ "it must be self-contained")
363
+
364
+
365
+ def _audit(document: str) -> _Auditor:
366
+ auditor = _Auditor()
367
+ auditor.feed(document)
368
+ auditor.close()
369
+ return auditor
370
+
371
+
372
+ def _too_long(document: str, max_chars: int) -> list[str]:
373
+ if len(document) <= max_chars:
374
+ return []
375
+ return [f"document is {len(document)} characters; hard limit is "
376
+ f"{max_chars}"]
377
+
378
+
379
+ def validate_text(document: str, max_words: int, max_chars: int) -> list[str]:
380
+ """The generic size guard every text document gets."""
381
+ errors = _too_long(document, max_chars)
382
+ if not document.strip():
383
+ errors.append("the document is empty")
384
+ words = len(document.split())
385
+ if words > max_words:
386
+ errors.append(f"document has {words} words; hard limit is {max_words}")
387
+ return _dedupe(errors)
388
+
389
+
390
+ def _validate_markup(document: str, max_words: int, max_chars: int,
391
+ root: str, shape: str) -> list[str]:
392
+ """Size, safety, nesting, and truncation for one markup document."""
393
+ errors = _too_long(document, max_chars)
394
+ lowered = document.lower()
395
+ if f"<{root}" not in lowered:
396
+ errors.append(f"document must be {shape}")
397
+ if f"</{root}>" not in lowered:
398
+ # The one reliable truncation guard: a cut-off rewrite never ends.
399
+ errors.append(f"document does not end with </{root}>; it looks "
400
+ "truncated")
401
+ try:
402
+ auditor = _audit(document)
403
+ except Exception as error: # noqa: BLE001 - reported to the model
404
+ errors.append(f"document does not parse as markup: {error}")
405
+ return _dedupe(errors)
406
+ errors += auditor.errors
407
+ if auditor.stack:
408
+ errors.append("unclosed tag(s): "
409
+ + ", ".join(f"<{tag}>" for tag in auditor.stack))
410
+ if auditor.words > max_words:
411
+ # Markup is not counted: this is what a reader of the page would see.
412
+ errors.append(f"document has {auditor.words} visible words; hard "
413
+ f"limit is {max_words}")
414
+ return _dedupe(errors)
415
+
416
+
417
+ def validate_html(document: str, max_words: int, max_chars: int) -> list[str]:
418
+ """Everything wrong with a candidate HTML page, or an empty list."""
419
+ return _validate_markup(document, max_words, max_chars, "html",
420
+ "a standalone page with an <html> tag")
421
+
422
+
423
+ def validate_svg(document: str, max_words: int, max_chars: int) -> list[str]:
424
+ """Everything wrong with a candidate standalone SVG, or an empty list."""
425
+ return _validate_markup(document, max_words, max_chars, "svg",
426
+ "one standalone <svg> element")
427
+
428
+
429
+ VALIDATORS = {"text": validate_text, "html": validate_html,
430
+ "svg": validate_svg}
431
+
432
+
433
+ # --------------------------------------------------------------------------
434
+
435
+ SVG_RE = re.compile(r"<svg\b.*?</svg>", re.DOTALL | re.IGNORECASE)
436
+
437
+
438
+ def _render_media_enabled() -> bool:
439
+ return os.environ.get("SDM_RENDER_MEDIA", "1") != "0"
440
+
441
+
442
+ FRAME_WIDTH, FRAME_HEIGHT = 640, 360
443
+
444
+
445
+ def _svg_png(svg: str) -> bytes | None:
446
+ """Rasterize one SVG in process; None when no renderer is available."""
447
+ try:
448
+ os.environ.setdefault("DYLD_FALLBACK_LIBRARY_PATH",
449
+ "/opt/homebrew/lib")
450
+ import cairosvg
451
+
452
+ if "xmlns" not in svg:
453
+ svg = svg.replace(
454
+ "<svg", '<svg xmlns="http://www.w3.org/2000/svg"', 1)
455
+ return cairosvg.svg2png(bytestring=svg.encode(),
456
+ output_width=FRAME_WIDTH,
457
+ output_height=FRAME_HEIGHT,
458
+ background_color="white")
459
+ except Exception: # noqa: BLE001 - fall back to QuickLook below
460
+ return None
461
+
462
+
463
+ def _svg_png_quicklook(svg: str, number: int = 1) -> bytes | None:
464
+ """The macOS fallback rasterizer, via QuickLook on a wrapper page."""
465
+ if not shutil.which("qlmanage"):
466
+ return None
467
+ page = (
468
+ '<!DOCTYPE html><html><head><meta charset="utf-8"><style>'
469
+ f"body{{margin:0;width:{FRAME_WIDTH}px;height:{FRAME_HEIGHT}px;"
470
+ "display:flex;align-items:center;justify-content:center;"
471
+ "background:#fff}"
472
+ f"</style></head><body>{svg}</body></html>"
473
+ )
474
+ with tempfile.TemporaryDirectory() as scratch:
475
+ source = Path(scratch) / f"frame_{number:02d}.html"
476
+ source.write_text(page)
477
+ subprocess.run(
478
+ ["qlmanage", "-t", "-s", str(FRAME_WIDTH), "-o", scratch,
479
+ str(source)],
480
+ capture_output=True, timeout=60, check=False,
481
+ )
482
+ thumb = source.with_suffix(".html.png")
483
+ return thumb.read_bytes() if thumb.exists() else None
484
+
485
+
486
+ def rasterize(svg: str, number: int = 1) -> bytes:
487
+ """One SVG as PNG bytes, or a clear error about the missing renderer."""
488
+ png = _svg_png(svg) or _svg_png_quicklook(svg, number)
489
+ if png is None:
490
+ raise SetupNeeded(
491
+ "no SVG rasterizer available, so a format that renders SVG "
492
+ "cannot run. Install cairosvg (pip install cairosvg, plus "
493
+ "'brew install cairo' on macOS), or choose a text-only format "
494
+ "in format.md."
495
+ )
496
+ return png
497
+
498
+
499
+ def _canvas(png: bytes) -> bytes:
500
+ """Letterbox a rendered frame onto a fixed white canvas."""
501
+ from PIL import Image
502
+
503
+ size = (FRAME_WIDTH, FRAME_HEIGHT)
504
+ canvas = Image.new("RGB", size, "white")
505
+ tile = Image.open(io.BytesIO(png)).convert("RGB")
506
+ tile.thumbnail(size)
507
+ canvas.paste(tile, ((FRAME_WIDTH - tile.width) // 2,
508
+ (FRAME_HEIGHT - tile.height) // 2))
509
+ buffer = io.BytesIO()
510
+ canvas.save(buffer, format="PNG")
511
+ return buffer.getvalue()
512
+
513
+
514
+ # --------------------------------------------------------------------------
515
+
516
+ def codecs() -> dict:
517
+ """The audio and video codecs, imported only when a format asks for one.
518
+
519
+ They live in ``signals.py`` because they are the only code in this project
520
+ that wants numpy, Pillow, or ffmpeg. Nothing in the learning loop touches
521
+ them, so a text-only install never pays for them.
522
+ """
523
+ from . import signals
524
+
525
+ return signals.CODECS
526
+
527
+
528
+ # --------------------------------------------------------------------------
529
+
530
+ class SkillFormat:
531
+ """One skill format: a mechanism parameterized by a format.md section.
532
+
533
+ Subclasses are the mechanisms. Each owns how the hypothesis is stored,
534
+ how the model perceives it, and how a proposed revision is validated.
535
+ """
536
+
537
+ mechanism = ""
538
+ block = "skill_document"
539
+ skill_file = "skill.txt"
540
+ cites_rules = False
541
+ channel = ""
542
+ noun = "skill document"
543
+
544
+ def __init__(self, name: str, spec: str = "", knobs: dict | None = None,
545
+ seed: str | None = None):
546
+ self.name = name
547
+ self.spec = spec.strip()
548
+ self.knobs = dict(knobs or {})
549
+ self._seed = seed
550
+
551
+ def __repr__(self):
552
+ return f"SkillFormat(name={self.name!r}, mechanism={self.mechanism!r})"
553
+
554
+ # -- limits ----------------------------------------------------------
555
+ def limits(self, **overrides) -> dict:
556
+ """The knobs from format.md, with explicit arguments winning."""
557
+ merged = {"max_words": 8000, "max_chars": 30000, "max_rules": 100,
558
+ "max_semantic": 4, "max_support": 4}
559
+ merged.update({key: value for key, value in self.knobs.items()
560
+ if key in merged})
561
+ merged.update({key: value for key, value in overrides.items()
562
+ if value is not None})
563
+ return merged
564
+
565
+ # -- the hypothesis --------------------------------------------------
566
+ def seed(self, config: dict) -> str:
567
+ return self._seed or ""
568
+
569
+ def new_state(self, config: dict) -> dict:
570
+ return {**_base_state(config), "doc": self.seed(config)}
571
+
572
+ def source(self, state: dict) -> str:
573
+ """The document as the model wrote it."""
574
+ return state.get("doc", "")
575
+
576
+ def words(self, state: dict) -> int:
577
+ return len(self.source(state).split())
578
+
579
+ def view(self, state: dict, cache=None) -> tuple[str, list[bytes]]:
580
+ """What the model perceives: prompt text plus attached images."""
581
+ return self.source(state), []
582
+
583
+ def credit(self, state: dict, predictions: list[dict],
584
+ labels: list[str]) -> None:
585
+ """Attribute a batch outcome to the parts of the skill it cited."""
586
+
587
+ def render_media(self, state: dict, run_dir: Path) -> None:
588
+ """Write any playable or viewable rendering beside the source."""
589
+
590
+ # -- proposing a revision --------------------------------------------
591
+ def update_instructions(self, limits: dict) -> str:
592
+ raise NotImplementedError
593
+
594
+ def validate(self, document: str, limits: dict) -> list[str]:
595
+ """Every reason the harness refuses this document, or nothing."""
596
+ raise NotImplementedError
597
+
598
+ def propose(self, state: dict, raw: str,
599
+ limits: dict) -> tuple[list[tuple], list[str]]:
600
+ """Parse, validate, and apply one revision, all or nothing.
601
+
602
+ The default is whole-document replacement, which is what every format
603
+ but the edit grammar does.
604
+ """
605
+ document, errors = parse_document(raw, self.block)
606
+ if errors:
607
+ return [], errors
608
+ if document is KEEP:
609
+ return [], []
610
+ errors = self.validate(document, limits)
611
+ if errors:
612
+ return [], errors
613
+ state["doc"] = document
614
+ return [("REPLACE", self.name)], []
615
+
616
+ # -- prompts ---------------------------------------------------------
617
+ def prediction_line(self) -> str:
618
+ if self.cites_rules:
619
+ return ("P01 | class | R001,R002 or none | evidence of at most "
620
+ "20 words")
621
+ return "P01 | class | evidence of at most 20 words"
622
+
623
+ def skill_section(self, view_text: str) -> str:
624
+ return f'<skill_file format="{self.name}">\n{view_text}</skill_file>'
625
+
626
+ def rendering_note(self) -> str:
627
+ """One sentence about how the document is presented, if it needs one."""
628
+ return ""
629
+
630
+ def reading_instruction(self) -> str:
631
+ return (f"Use the learned {self.name} skill document to classify "
632
+ f"every input independently. {self.rendering_note()}All "
633
+ "inputs share the same document snapshot; do not learn from "
634
+ "the other inputs in this batch. If the document is empty or "
635
+ "ambiguous, make your best guess.")
636
+
637
+ def predict_prompt(self, description: str, classes, view_text: str,
638
+ rows: list[dict], representation: str) -> str:
639
+ head = (f"You are the prediction component of a supervised learner.\n"
640
+ f'{description}\nClasses: {", ".join(classes)}.')
641
+ tail = (f"Output ONLY a <predictions> block with exactly {len(rows)} "
642
+ f"lines, one per\ninput, in this format:\n"
643
+ f"{self.prediction_line()}\n\nThe class must be exactly one "
644
+ f'of: {", ".join(classes)}.\n</predictions>')
645
+ # A no-document format contributes no skill section, and an image
646
+ # representation contributes no input text, so skip empty parts
647
+ # rather than leaving a hole in the prompt.
648
+ parts = [head, self.reading_instruction(),
649
+ self.skill_section(view_text),
650
+ _inputs(rows, representation), tail]
651
+ return "\n\n".join(part for part in parts if part)
652
+
653
+
654
+ class EditsFormat(SkillFormat):
655
+ """Numbered rules revised by one bounded, atomic diff."""
656
+
657
+ mechanism = "edits"
658
+ block = "edits"
659
+ skill_file = "skill.md"
660
+ cites_rules = True
661
+ noun = "bounded Markdown rulebook"
662
+
663
+ def new_state(self, config: dict) -> dict:
664
+ return new_state(config)
665
+
666
+ def source(self, state: dict) -> str:
667
+ return render_rulebook(state)
668
+
669
+ def credit(self, state, predictions, labels):
670
+ credit(state, predictions, labels)
671
+
672
+ def update_instructions(self, limits: dict) -> str:
673
+ return f"""Return only an <edits> block. Allowed operations are:
674
+ {EDIT_GRAMMAR}
675
+
676
+ Use at most {limits["max_semantic"]} total ADD/REVISE/DELETE operations and at
677
+ most {limits["max_support"]} SUPPORT operations. {self.spec} Every predicate
678
+ states a reusable condition on the current input alone and contains at most
679
+ {CONDITION_WORDS} words. The complete rulebook is bounded to
680
+ {limits["max_rules"]} rules and {limits["max_words"]} words.
681
+ </edits>"""
682
+
683
+ def propose(self, state, raw, limits):
684
+ edits, errors = parse_edits(raw)
685
+ if edits is None:
686
+ return [], errors
687
+ semantic = sum(edit[0] in {"ADD", "REVISE", "DELETE"}
688
+ for edit in edits)
689
+ supports = sum(edit[0] == "SUPPORT" for edit in edits)
690
+ if semantic > limits["max_semantic"]:
691
+ errors.append(f"semantic edit count {semantic} exceeds "
692
+ f'{limits["max_semantic"]}')
693
+ if supports > limits["max_support"]:
694
+ errors.append(f"support edit count {supports} exceeds "
695
+ f'{limits["max_support"]}')
696
+ if not errors:
697
+ errors += apply_edits(state, edits, limits["max_words"],
698
+ limits["max_rules"])
699
+ return edits, errors
700
+
701
+
702
+ class DocumentFormat(SkillFormat):
703
+ """A whole text document, replaced outright or kept with NOOP.
704
+
705
+ ``validator`` picks the harness-enforced checks (``text``, ``html``, or
706
+ ``svg``); ``render: svg`` additionally shows the model its own document
707
+ rasterized, so it can see what it drew.
708
+ """
709
+
710
+ mechanism = "document"
711
+ noun = "bounded skill document"
712
+
713
+ @property
714
+ def validator(self):
715
+ return VALIDATORS.get(self.knobs.get("validator", "text"),
716
+ validate_text)
717
+
718
+ @property
719
+ def block(self) -> str:
720
+ return str(self.knobs.get("block", f"skill_{self.name}"))
721
+
722
+ @property
723
+ def skill_file(self) -> str:
724
+ suffix = {"html": "html", "svg": "svg"}.get(
725
+ self.knobs.get("validator"), "txt")
726
+ return f"skill.{suffix}"
727
+
728
+ @property
729
+ def rasterized(self) -> bool:
730
+ return str(self.knobs.get("render", "")).lower() == "svg"
731
+
732
+ def seed(self, config: dict) -> str:
733
+ if self._seed:
734
+ return self._seed
735
+ return _default_seed(self.knobs.get("validator", "text"), config)
736
+
737
+ def view(self, state, cache=None):
738
+ source = self.source(state)
739
+ if not (self.rasterized and _render_media_enabled()):
740
+ return source, []
741
+ svg = SVG_RE.search(source)
742
+ if svg is None:
743
+ return source, []
744
+ return source, [_canvas(rasterize(svg.group(0)))]
745
+
746
+ def render_media(self, state, run_dir):
747
+ if not (self.rasterized and _render_media_enabled()):
748
+ return
749
+ svg = SVG_RE.search(self.source(state))
750
+ if svg is not None:
751
+ (run_dir / "skill.png").write_bytes(
752
+ _canvas(rasterize(svg.group(0))))
753
+
754
+ def rendering_note(self) -> str:
755
+ if not self.rasterized:
756
+ return ""
757
+ return ("Its rendering is attached as an image; read the drawing as "
758
+ "part of the hypothesis. ")
759
+
760
+ def update_instructions(self, limits: dict) -> str:
761
+ return f"""Return ONLY a <{self.block}> block holding the complete
762
+ replacement document, or NOOP to keep the current one.
763
+
764
+ {self.spec}
765
+
766
+ The document is a hypothesis, not an example log: no input IDs, no labels
767
+ copied from individual examples, no memorized per-example values. Every
768
+ statement must be checkable on a new unlabeled input alone. Keep what already
769
+ works and revise conservatively. Hard limits: {limits["max_words"]} words and
770
+ {limits["max_chars"]} characters."""
771
+
772
+ def validate(self, document, limits):
773
+ return self.validator(document, limits["max_words"],
774
+ limits["max_chars"])
775
+
776
+
777
+ class SignalFormat(SkillFormat):
778
+ """A source the model writes but never re-reads: only the channel view.
779
+
780
+ At update time the model sees its source *and* what the source becomes
781
+ through the channel, so it can learn what survives. At prediction time
782
+ the source is withheld.
783
+ """
784
+
785
+ mechanism = "signal"
786
+
787
+ @property
788
+ def codec(self):
789
+ available = codecs()
790
+ name = self.knobs.get("signal", self.name)
791
+ if name not in available:
792
+ raise SetupNeeded(
793
+ f"format {self.name!r} needs 'signal: audio' or "
794
+ f"'signal: video' in format.md; got {name!r}")
795
+ return available[name]
796
+
797
+ @property
798
+ def block(self) -> str:
799
+ return self.codec.block
800
+
801
+ @property
802
+ def skill_file(self) -> str:
803
+ return self.codec.skill_file
804
+
805
+ @property
806
+ def channel(self) -> str:
807
+ return self.codec.channel
808
+
809
+ @property
810
+ def noun(self) -> str:
811
+ return self.codec.noun
812
+
813
+ def seed(self, config: dict) -> str:
814
+ return self._seed or self.codec.seed
815
+
816
+ def view(self, state, cache=None):
817
+ return self.codec.view(self.source(state), _cache_dir(cache))
818
+
819
+ def render_media(self, state, run_dir):
820
+ if _render_media_enabled():
821
+ self.codec.render_media(self.source(state), Path(run_dir))
822
+
823
+ def skill_section(self, view_text: str) -> str:
824
+ return f"<skill_signal_analysis>\n{view_text}\n</skill_signal_analysis>"
825
+
826
+ def reading_instruction(self) -> str:
827
+ return (f"Your learned skill is stored ONLY as a rendered "
828
+ f"{self.codec.name} signal. You are not shown its source. "
829
+ f"You perceive it through {self.channel}. Read the reading "
830
+ "procedure and per-class signatures that the signal presents, "
831
+ "and apply them. Classify every input independently; if the "
832
+ "signal is uninformative, make your best guess.")
833
+
834
+ def update_instructions(self, limits: dict) -> str:
835
+ return f"""Your entire long-term memory for this task is one rendered
836
+ {self.codec.name} signal. At prediction time you will NOT see its source; you
837
+ will perceive the signal only through {self.channel}. Write the replacement
838
+ source so that the channel view still presents, clearly enough for your
839
+ prediction step to use, the general reading procedure, per-class signatures,
840
+ and known error modes learned so far.
841
+
842
+ Return ONLY a <{self.block}> block holding the complete replacement source, or
843
+ NOOP to keep the current one.
844
+
845
+ {self.spec}"""
846
+
847
+ def validate(self, document, limits):
848
+ # The codec's own structural caps (events, seconds, frames) bind
849
+ # here; a signal has no meaningful word count.
850
+ return self.codec.validate(document)
851
+
852
+
853
+ class NoFormat(SkillFormat):
854
+ """No skill document at all: the zero-training control.
855
+
856
+ It contributes an empty skill section, so the shared prediction prompt
857
+ omits the section entirely rather than showing a blank one.
858
+ """
859
+
860
+ mechanism = "none"
861
+ skill_file = "skill.txt"
862
+
863
+ def new_state(self, config: dict) -> dict:
864
+ return _base_state(config)
865
+
866
+ def source(self, state):
867
+ return ""
868
+
869
+ def view(self, state, cache=None):
870
+ return "", []
871
+
872
+ def skill_section(self, view_text: str) -> str:
873
+ return ""
874
+
875
+ def reading_instruction(self) -> str:
876
+ return ("Classify every input independently from your own prior "
877
+ "knowledge. There is no learned skill document. Do not learn "
878
+ "from the other inputs in this batch.")
879
+
880
+ def update_instructions(self, limits):
881
+ raise SetupNeeded("the baseline control has no document to revise")
882
+
883
+ def propose(self, state, raw, limits):
884
+ raise SetupNeeded("the baseline control has no document to revise")
885
+
886
+
887
+ MECHANISMS = {"edits": EditsFormat, "document": DocumentFormat,
888
+ "signal": SignalFormat, "none": NoFormat}
889
+
890
+ NO_FORMAT = NoFormat("none")
891
+
892
+ KEEP = object() # the sentinel for a NOOP revision
893
+
894
+
895
+ def parse_document(raw: str, block: str):
896
+ """The replacement document in ``raw``: a string, ``KEEP``, or errors."""
897
+ if raw.strip().upper() == "NOOP":
898
+ return KEEP, []
899
+ pattern = re.compile(rf"<{re.escape(block)}>\s*(.*?)\s*</{re.escape(block)}>",
900
+ re.DOTALL | re.IGNORECASE)
901
+ match = pattern.search(raw)
902
+ if not match:
903
+ return None, [f"missing <{block}> block"]
904
+ body = match.group(1)
905
+ if body.strip().upper() == "NOOP":
906
+ return KEEP, []
907
+ return body, []
908
+
909
+
910
+ def _default_seed(validator: str, config: dict) -> str:
911
+ """The empty hypothesis: the section layout, with nothing learned yet."""
912
+ title = config.get("title", "Skill document")
913
+ if validator == "svg":
914
+ return f"""<svg width="640" height="480" viewBox="0 0 640 480">
915
+ <text x="20" y="40" font-size="22">{title}</text>
916
+ <text x="20" y="80" font-size="16">nothing learned yet</text>
917
+ </svg>
918
+ """
919
+ if validator == "html":
920
+ sections = "\n".join(
921
+ f"<h3>{label}</h3>\n<p>No reliable signature yet.</p>"
922
+ for label in config.get("classes", ()))
923
+ return f"""<!DOCTYPE html>
924
+ <html>
925
+ <head><meta charset="utf-8"><title>{title}</title></head>
926
+ <body>
927
+ <h1>{title}</h1>
928
+ <p>This complete document is the learned hypothesis. It must describe only
929
+ reusable evidence available in a new unlabeled input.</p>
930
+ <h2>Reading procedure</h2>
931
+ <p>No learned cross-class procedure yet.</p>
932
+ <h2>Per-class signatures</h2>
933
+ {sections}
934
+ <h2>Decision boundaries and error log</h2>
935
+ <p>No errors recorded yet.</p>
936
+ </body>
937
+ </html>
938
+ """
939
+ signatures = "\n".join(f"### {label}\nNo reliable signature yet.\n"
940
+ for label in config.get("classes", ()))
941
+ return (f"# {title}\n\n## Reading procedure\nNo learned cross-class "
942
+ f"procedure yet.\n\n## Per-class signatures\n{signatures}")
943
+
944
+
945
+ def _cache_dir(cache) -> Path:
946
+ if cache is not None:
947
+ return Path(cache)
948
+ return Path(tempfile.gettempdir()) / "sdm_view_cache"
949
+
950
+
951
+ # --------------------------------------------------------------------------
952
+
953
+ FRONT_MATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n", re.DOTALL)
954
+ SECTION_RE = re.compile(r"^##\s+(\S+)\s*$", re.MULTILINE)
955
+ KNOB_RE = re.compile(r"^([a-z][a-z0-9_]*)\s*:\s*(\S.*?)\s*$")
956
+ SEED_RE = re.compile(r"```seed\s*\n(.*?)```", re.DOTALL)
957
+
958
+ DEFAULT_FORMAT_MD = '''---
959
+ format: markdown
960
+ ---
961
+
962
+ # Skill formats
963
+
964
+ Pick a format by editing the `format:` line above. Each section below defines
965
+ one. The `mechanism:` line says which machinery the harness uses:
966
+
967
+ - `edits` numbered rules revised by one bounded atomic diff
968
+ - `document` one text document replaced outright, or kept with NOOP
969
+ - `signal` a source the model writes but never re-reads, only its rendering
970
+
971
+ Sections using `edits` or `document` are pure configuration, so a new
972
+ text-based format needs nothing but another section here. Safety checks stay
973
+ in Python: `validator:` selects them.
974
+
975
+ ## markdown
976
+
977
+ mechanism: edits
978
+ max_words: 8000
979
+ max_rules: 100
980
+
981
+ Use one operation per line and never put "|" inside a condition. Rules are a
982
+ rulebook, not an example log: no input IDs, no labels copied from individual
983
+ examples, no memorized values. Prefer revising an existing rule over adding a
984
+ near-duplicate. Write only the predicate: do not start it with IF or end it
985
+ with a prediction. IDs and the reliability counters are maintained by the
986
+ harness; do not write them.
987
+
988
+ ## html
989
+
990
+ mechanism: document
991
+ validator: html
992
+ block: skill_html
993
+ max_words: 8000
994
+ max_chars: 30000
995
+
996
+ One standalone static page, ending with `</html>`. No scripts, no frames, no
997
+ forms, no event handler attributes, and no external references of any kind.
998
+ Inline SVG is encouraged where a sketch beats a sentence: small labelled
999
+ prototype drawings per class, a workflow diagram for the reading procedure,
1000
+ and contrast pairs for classes that get confused. Track recurring mistakes in
1001
+ an error-log section so the same confusion is not repeated.
1002
+
1003
+ ## image
1004
+
1005
+ mechanism: document
1006
+ validator: svg
1007
+ render: svg
1008
+ block: skill_svg
1009
+ max_words: 2000
1010
+ max_chars: 20000
1011
+
1012
+ One standalone `<svg>` element, ending with `</svg>`. This is a diagram you
1013
+ will be shown as a picture on every future prediction, so draw the decision
1014
+ procedure rather than writing paragraphs: labelled class prototypes, a
1015
+ flowchart of the reading procedure, and contrast pairs for confusable classes.
1016
+ Text inside the SVG is fine, but anything that only makes sense as prose
1017
+ belongs in a caption. No external references.
1018
+
1019
+ ## audio
1020
+
1021
+ mechanism: signal
1022
+ signal: audio
1023
+
1024
+ Write a synthesis score, one event per line:
1025
+ TONE <frequency_hz 50-4000> <seconds 0.05-2>
1026
+ REST <seconds 0.05-2>
1027
+ Lines starting with # are stripped before rendering and will NOT survive.
1028
+ At most 400 events and 30s total.
1029
+
1030
+ ## video
1031
+
1032
+ mechanism: signal
1033
+ signal: video
1034
+
1035
+ Write a standalone HTML storyboard. Each frame is:
1036
+ <section class="frame" data-seconds="N">
1037
+ <svg width="640" height="360" viewBox="0 0 640 360"> ... </svg>
1038
+ </section>
1039
+ with 1 <= N <= 10, at most 12 frames, 60s total. No scripts, no external
1040
+ references. Anything you draw or write inside the SVGs survives as pixels;
1041
+ nothing outside them does.
1042
+ '''
1043
+
1044
+
1045
+ PACKAGED_FORMAT_FILE = Path(__file__).parent / "format.md"
1046
+
1047
+
1048
+ def format_file(path=None) -> Path:
1049
+ """Which ``format.md`` this run reads, and why that order.
1050
+
1051
+ The whole point of the file is that a user edits it, so an installed copy
1052
+ under ``site-packages`` is the last resort, not the first. A project keeps
1053
+ its own ``format.md`` beside its data and gets it picked up automatically;
1054
+ ``sdm init`` writes one out to start from.
1055
+
1056
+ 1. an explicit path, or ``SDM_FORMAT_FILE``
1057
+ 2. ``./format.md`` in the working directory
1058
+ 3. the copy shipped inside the package
1059
+ """
1060
+ if path is not None:
1061
+ return Path(path)
1062
+ override = os.environ.get("SDM_FORMAT_FILE")
1063
+ if override:
1064
+ return Path(override)
1065
+ local = Path.cwd() / "format.md"
1066
+ if local.is_file():
1067
+ return local
1068
+ return PACKAGED_FORMAT_FILE
1069
+
1070
+
1071
+ def write_format_file(target=None, *, force: bool = False) -> Path:
1072
+ """Copy the packaged ``format.md`` into a project so it can be edited."""
1073
+ destination = Path(target or Path.cwd() / "format.md")
1074
+ if destination.is_dir():
1075
+ destination = destination / "format.md"
1076
+ if destination.exists() and not force:
1077
+ raise SetupNeeded(
1078
+ f"{destination} already exists; pass force=True to overwrite it.")
1079
+ destination.parent.mkdir(parents=True, exist_ok=True)
1080
+ destination.write_text(PACKAGED_FORMAT_FILE.read_text())
1081
+ return destination
1082
+
1083
+
1084
+ def _parse_knobs(body: str) -> tuple[dict, str]:
1085
+ """The contiguous ``key: value`` lines at the top, then the prose."""
1086
+ knobs: dict = {}
1087
+ lines = body.splitlines()
1088
+ index = 0
1089
+ while index < len(lines):
1090
+ line = lines[index].strip()
1091
+ if not line:
1092
+ index += 1
1093
+ continue
1094
+ match = KNOB_RE.match(line)
1095
+ if not match:
1096
+ break
1097
+ value = match.group(2)
1098
+ knobs[match.group(1)] = int(value) if value.isdigit() else value
1099
+ index += 1
1100
+ return knobs, "\n".join(lines[index:]).strip()
1101
+
1102
+
1103
+ def parse_format_md(text: str) -> tuple[str | None, dict]:
1104
+ """Read a format.md into its default choice and its format objects."""
1105
+ default = None
1106
+ front = FRONT_MATTER_RE.match(text)
1107
+ if front:
1108
+ for line in front.group(1).splitlines():
1109
+ match = KNOB_RE.match(line.strip())
1110
+ if match and match.group(1) == "format":
1111
+ default = match.group(2)
1112
+ text = text[front.end():]
1113
+
1114
+ built: dict = {}
1115
+ matches = list(SECTION_RE.finditer(text))
1116
+ for position, match in enumerate(matches):
1117
+ name = match.group(1)
1118
+ stop = (matches[position + 1].start() if position + 1 < len(matches)
1119
+ else len(text))
1120
+ body = text[match.end():stop]
1121
+ seed_match = SEED_RE.search(body)
1122
+ seed = seed_match.group(1) if seed_match else None
1123
+ if seed_match:
1124
+ body = body[:seed_match.start()] + body[seed_match.end():]
1125
+ knobs, spec = _parse_knobs(body)
1126
+ mechanism = knobs.pop("mechanism", None)
1127
+ if mechanism is None:
1128
+ continue
1129
+ if mechanism not in MECHANISMS:
1130
+ raise SetupNeeded(
1131
+ f"format {name!r} in format.md asks for unknown mechanism "
1132
+ f"{mechanism!r}; choose one of "
1133
+ f"{', '.join(sorted(MECHANISMS))}")
1134
+ built[name] = MECHANISMS[mechanism](name, spec, knobs, seed)
1135
+ return default, built
1136
+
1137
+
1138
+ def load_formats(path=None) -> tuple[str, dict, str]:
1139
+ """The default format name, every defined format, and the file's hash."""
1140
+ file = format_file(path)
1141
+ text = file.read_text() if file.is_file() else DEFAULT_FORMAT_MD
1142
+ default, built = parse_format_md(text)
1143
+ if not built:
1144
+ raise SetupNeeded(
1145
+ f"{file} defines no formats. Every section needs a 'mechanism:' "
1146
+ "line; see the shipped format.md.")
1147
+ if default is None or default not in built:
1148
+ default = next(iter(built))
1149
+ return default, built, hashlib.sha256(text.encode()).hexdigest()[:16]
1150
+
1151
+
1152
+ def formats(path=None) -> dict:
1153
+ """Every skill format defined in ``format.md``, by name."""
1154
+ return load_formats(path)[1]
1155
+
1156
+
1157
+ def get_format(name=None, path=None) -> SkillFormat:
1158
+ """One skill format by name, or the default ``format.md`` selects."""
1159
+ default, built, digest = load_formats(path)
1160
+ chosen = name or default
1161
+ if chosen not in built:
1162
+ raise ValueError(
1163
+ f"unknown skill format {chosen!r}; format.md defines "
1164
+ f"{', '.join(sorted(built))}")
1165
+ picked = built[chosen]
1166
+ picked.digest = digest
1167
+ return picked
1168
+
1169
+
1170
+ def format_of(state: dict) -> SkillFormat:
1171
+ """The format a saved state was produced with."""
1172
+ name = state.get("config", {}).get("format", "markdown")
1173
+ if name is None:
1174
+ return NO_FORMAT
1175
+ return get_format(name)
1176
+
1177
+
1178
+ def skill_filename(state: dict) -> str:
1179
+ return state.get("config", {}).get("skill_file", "skill.md")
1180
+
1181
+
1182
+ # --------------------------------------------------------------------------