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/__init__.py +71 -0
- sdm/__main__.py +51 -0
- sdm/data.py +238 -0
- sdm/format.md +84 -0
- sdm/formats.py +1182 -0
- sdm/gateway.py +169 -0
- sdm/learn.py +699 -0
- sdm/signals.py +386 -0
- sdm_learn-0.1.0.dev0.dist-info/METADATA +35 -0
- sdm_learn-0.1.0.dev0.dist-info/RECORD +14 -0
- sdm_learn-0.1.0.dev0.dist-info/WHEEL +5 -0
- sdm_learn-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- sdm_learn-0.1.0.dev0.dist-info/licenses/LICENSE +201 -0
- sdm_learn-0.1.0.dev0.dist-info/top_level.txt +1 -0
sdm/__init__.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Self-documenting models: the document *is* the model.
|
|
2
|
+
|
|
3
|
+
There are no weights and no classifier head. A document holds the learned
|
|
4
|
+
hypothesis, an LLM reads it to classify inputs, and then rewrites it from the
|
|
5
|
+
revealed labels. What you train is readable, and what you read is what runs.
|
|
6
|
+
|
|
7
|
+
Four components plug into one interface, one file each, and the imports only
|
|
8
|
+
point downward:
|
|
9
|
+
|
|
10
|
+
gateway.py the wire to an LLM, and SetupNeeded
|
|
11
|
+
data.py the dataset, the input representation, row rendering
|
|
12
|
+
formats.py how the hypothesis is written down and revised
|
|
13
|
+
signals.py the audio and video codecs, imported only on demand
|
|
14
|
+
learn.py run artifacts, the gate, the baseline, train / evaluate
|
|
15
|
+
|
|
16
|
+
``import sdm`` is the only entry point: everything public is re-exported here,
|
|
17
|
+
so callers never see the split.
|
|
18
|
+
|
|
19
|
+
The gate algorithm runs this loop per cycle::
|
|
20
|
+
|
|
21
|
+
predict the feedback batches from the current document (labels hidden)
|
|
22
|
+
reveal the labels and ask for one bounded revision
|
|
23
|
+
score the current and revised documents head to head on protected rows
|
|
24
|
+
keep the revision only if it wins
|
|
25
|
+
|
|
26
|
+
``state.json`` is the truth; the skill file is rendered from it.
|
|
27
|
+
|
|
28
|
+
import sdm
|
|
29
|
+
|
|
30
|
+
data = sdm.dataset(
|
|
31
|
+
train=[{"text": "wire me $500 today", "label": "spam"},
|
|
32
|
+
{"text": "lunch at one?", "label": "ham"}],
|
|
33
|
+
classes=("spam", "ham"),
|
|
34
|
+
description="Each input is one email body.",
|
|
35
|
+
)
|
|
36
|
+
skill = sdm.train(data, algo="gate")
|
|
37
|
+
print(skill)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
from .data import (REPRESENTATIONS, Dataset, Report, content, dataset,
|
|
43
|
+
describe, from_jsonl, labeled)
|
|
44
|
+
from .formats import (DEFAULT_FORMAT_MD, PACKAGED_FORMAT_FILE, apply_edits, clean_condition, codecs,
|
|
45
|
+
credit, format_file, formats, get_format, load_formats,
|
|
46
|
+
new_state, parse_edits, parse_predictions, render,
|
|
47
|
+
validate_html, validate_svg, validate_text,
|
|
48
|
+
write_format_file)
|
|
49
|
+
from .gateway import SetupNeeded
|
|
50
|
+
from .learn import (ALGOS, Skill, cycle_points, decide, evaluate, load,
|
|
51
|
+
parse_paired, stratified_holdout, train)
|
|
52
|
+
|
|
53
|
+
__version__ = "0.1.0.dev0"
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
# the API
|
|
57
|
+
"Dataset", "Report", "Skill", "SetupNeeded",
|
|
58
|
+
"dataset", "from_jsonl", "load", "train", "evaluate",
|
|
59
|
+
# the four components
|
|
60
|
+
"REPRESENTATIONS", "describe",
|
|
61
|
+
"formats", "get_format", "load_formats", "DEFAULT_FORMAT_MD",
|
|
62
|
+
"format_file", "write_format_file", "PACKAGED_FORMAT_FILE",
|
|
63
|
+
"ALGOS", "codecs",
|
|
64
|
+
# the pieces the README and the checks reach for
|
|
65
|
+
"render", "new_state", "parse_edits", "apply_edits", "clean_condition",
|
|
66
|
+
"parse_predictions", "parse_paired", "credit",
|
|
67
|
+
"validate_text", "validate_html", "validate_svg",
|
|
68
|
+
"stratified_holdout", "cycle_points", "decide",
|
|
69
|
+
"content", "labeled",
|
|
70
|
+
"__version__",
|
|
71
|
+
]
|
sdm/__main__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""``python -m sdm``, and the ``sdm`` console script.
|
|
2
|
+
|
|
3
|
+
Small on purpose. The library is the product; this exists so a pip-installed
|
|
4
|
+
user can get an editable ``format.md`` and confirm the install works without
|
|
5
|
+
an API key.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from . import __version__, format_file, formats, write_format_file
|
|
14
|
+
from .gateway import SetupNeeded
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main(argv=None) -> int:
|
|
18
|
+
parser = argparse.ArgumentParser(
|
|
19
|
+
prog="sdm", description="Self-documenting models.")
|
|
20
|
+
parser.add_argument("--version", action="version",
|
|
21
|
+
version=f"sdm {__version__}")
|
|
22
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
23
|
+
|
|
24
|
+
initialize = commands.add_parser(
|
|
25
|
+
"init", help="write an editable format.md into this directory")
|
|
26
|
+
initialize.add_argument("--force", action="store_true",
|
|
27
|
+
help="overwrite an existing format.md")
|
|
28
|
+
|
|
29
|
+
commands.add_parser(
|
|
30
|
+
"formats", help="list the skill formats format.md defines")
|
|
31
|
+
|
|
32
|
+
options = parser.parse_args(argv)
|
|
33
|
+
try:
|
|
34
|
+
if options.command == "init":
|
|
35
|
+
path = write_format_file(force=options.force)
|
|
36
|
+
print(f"wrote {path}")
|
|
37
|
+
print("Edit the 'format:' line to change the skill format.")
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
active = format_file()
|
|
41
|
+
print(f"reading {active}")
|
|
42
|
+
for name, skill_format in sorted(formats().items()):
|
|
43
|
+
print(f" {name:10s} mechanism: {skill_format.mechanism}")
|
|
44
|
+
return 0
|
|
45
|
+
except SetupNeeded as error:
|
|
46
|
+
print(f"sdm: {error}", file=sys.stderr)
|
|
47
|
+
return 1
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
raise SystemExit(main())
|
sdm/data.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""The data half: what a row is, how it reaches the model, what comes back.
|
|
2
|
+
|
|
3
|
+
Two of the four components live here. The **input representation** decides how
|
|
4
|
+
one labeled row becomes prompt material: serialized into the text, or attached
|
|
5
|
+
as an image. The **dataset** is just rows with labels, plus the prose that
|
|
6
|
+
tells the model what a row means.
|
|
7
|
+
|
|
8
|
+
The row-rendering helpers at the bottom belong to the representation, not to
|
|
9
|
+
any skill format: every prompt in the project builds its input blocks through
|
|
10
|
+
them, so a new representation changes every prompt at once.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .gateway import _png_block, image_block
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
DEFAULT_TEXT_DESCRIPTION = (
|
|
23
|
+
"Each input is one example rendered as text. Infer the meaning of its "
|
|
24
|
+
"fields from the labeled examples you are shown."
|
|
25
|
+
)
|
|
26
|
+
DEFAULT_IMAGE_DESCRIPTION = (
|
|
27
|
+
"Each input is one image. Infer what distinguishes the classes from the "
|
|
28
|
+
"labeled examples you are shown."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TextRepresentation:
|
|
33
|
+
"""Rows are serialized into the prompt text.
|
|
34
|
+
|
|
35
|
+
``inline`` picks which half of this interface the prompts consult:
|
|
36
|
+
``serialize`` for a representation that becomes text, ``blocks`` for one
|
|
37
|
+
that becomes attachments. Never both.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
name = "text"
|
|
41
|
+
inline = True
|
|
42
|
+
default_description = DEFAULT_TEXT_DESCRIPTION
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def serialize(row: dict) -> str:
|
|
46
|
+
if "text" in row:
|
|
47
|
+
return str(row["text"])
|
|
48
|
+
return "\n".join(
|
|
49
|
+
f"{key}={value}" for key, value in sorted(row.items())
|
|
50
|
+
if key not in {"id", "label", "index", "image"})
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def blocks(rows: list[dict], tag: str, image_size: int) -> list:
|
|
54
|
+
return []
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ImageRepresentation(TextRepresentation):
|
|
58
|
+
"""Rows are attached as images, one content block each."""
|
|
59
|
+
|
|
60
|
+
name = "image"
|
|
61
|
+
inline = False
|
|
62
|
+
default_description = DEFAULT_IMAGE_DESCRIPTION
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def blocks(rows: list[dict], tag: str, image_size: int) -> list:
|
|
66
|
+
attached = []
|
|
67
|
+
for number, row in enumerate(rows, 1):
|
|
68
|
+
attached.append({"type": "text", "text": f"{tag} P{number:02d}"})
|
|
69
|
+
attached.append(image_block(row["image"], image_size))
|
|
70
|
+
return attached
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
REPRESENTATIONS = {"text": TextRepresentation, "image": ImageRepresentation}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# --------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class Dataset:
|
|
80
|
+
"""Labeled rows plus the little context the model needs to read them.
|
|
81
|
+
|
|
82
|
+
Every row needs a ``label``. Its input is ``text``, or ``image`` (a PIL
|
|
83
|
+
image, an RGB array, or a path), or any other scalar fields.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
train: list[dict] = field(default_factory=list)
|
|
87
|
+
test: list[dict] = field(default_factory=list)
|
|
88
|
+
classes: tuple[str, ...] = ()
|
|
89
|
+
name: str = "custom"
|
|
90
|
+
title: str = ""
|
|
91
|
+
description: str = ""
|
|
92
|
+
representation: str = "text"
|
|
93
|
+
image_size: int = 224
|
|
94
|
+
modality: str | None = None
|
|
95
|
+
|
|
96
|
+
def __post_init__(self):
|
|
97
|
+
self.train = [dict(row) for row in self.train]
|
|
98
|
+
self.test = [dict(row) for row in self.test]
|
|
99
|
+
if self.modality is not None:
|
|
100
|
+
self.representation = self.modality
|
|
101
|
+
self.modality = self.representation
|
|
102
|
+
if not self.classes:
|
|
103
|
+
self.classes = tuple(sorted(
|
|
104
|
+
{str(row["label"]) for row in self.train + self.test
|
|
105
|
+
if "label" in row}
|
|
106
|
+
))
|
|
107
|
+
self.classes = tuple(str(label) for label in self.classes)
|
|
108
|
+
if self.representation not in REPRESENTATIONS:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"unknown representation {self.representation!r}; choose one "
|
|
111
|
+
f"of {', '.join(sorted(REPRESENTATIONS))}")
|
|
112
|
+
if self.image_size <= 0:
|
|
113
|
+
raise ValueError("image_size must be positive")
|
|
114
|
+
if not self.title:
|
|
115
|
+
self.title = f"{self.name} skill"
|
|
116
|
+
for rows in (self.train, self.test):
|
|
117
|
+
for position, row in enumerate(rows):
|
|
118
|
+
row.setdefault("index", position)
|
|
119
|
+
|
|
120
|
+
def __len__(self):
|
|
121
|
+
return len(self.train)
|
|
122
|
+
|
|
123
|
+
def __repr__(self):
|
|
124
|
+
return (f"Dataset(name={self.name!r}, train={len(self.train)}, "
|
|
125
|
+
f"test={len(self.test)}, classes={len(self.classes)})")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class Report:
|
|
130
|
+
"""The outcome of scoring a frozen skill."""
|
|
131
|
+
|
|
132
|
+
correct: int
|
|
133
|
+
total: int
|
|
134
|
+
rows: list[dict] = field(default_factory=list)
|
|
135
|
+
requests: int = 0
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def accuracy(self) -> float:
|
|
139
|
+
return self.correct / self.total if self.total else 0.0
|
|
140
|
+
|
|
141
|
+
def __repr__(self):
|
|
142
|
+
return (f"Report(accuracy={self.accuracy:.1%}, "
|
|
143
|
+
f"{self.correct}/{self.total}, requests={self.requests})")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def dataset(train, test=(), **options) -> Dataset:
|
|
148
|
+
"""Wrap in-memory labeled rows into a :class:`Dataset`."""
|
|
149
|
+
return Dataset(train=list(train), test=list(test), **options)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def from_jsonl(train, test=None, *, limit=None, test_limit=None,
|
|
153
|
+
**options) -> Dataset:
|
|
154
|
+
"""Read JSONL rows; relative ``image`` paths resolve from each file."""
|
|
155
|
+
wants_images = (options.get("representation") == "image"
|
|
156
|
+
or options.get("modality") == "image")
|
|
157
|
+
|
|
158
|
+
def read(path, cap):
|
|
159
|
+
path = Path(path)
|
|
160
|
+
if not path.exists():
|
|
161
|
+
raise FileNotFoundError(f"missing {path}")
|
|
162
|
+
with open(path) as handle:
|
|
163
|
+
rows = [json.loads(line) for line in handle]
|
|
164
|
+
rows = rows if cap is None else rows[:cap]
|
|
165
|
+
if wants_images:
|
|
166
|
+
for row in rows:
|
|
167
|
+
image = row.get("image")
|
|
168
|
+
if isinstance(image, str) and not Path(image).is_absolute():
|
|
169
|
+
row["image"] = str((path.parent / image).resolve())
|
|
170
|
+
return rows
|
|
171
|
+
|
|
172
|
+
return Dataset(train=read(train, limit),
|
|
173
|
+
test=read(test, test_limit) if test else [], **options)
|
|
174
|
+
|
|
175
|
+
def describe(data: Dataset) -> str:
|
|
176
|
+
if data.description:
|
|
177
|
+
return data.description
|
|
178
|
+
return REPRESENTATIONS[data.representation].default_description
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def labeled(label: str, pngs) -> list[tuple[str, bytes]]:
|
|
182
|
+
"""Tag each rendering of one document so the model can tell them apart."""
|
|
183
|
+
return [(label, png) for png in pngs]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def content(prompt: str, rows: list[dict], representation: str, tag: str,
|
|
187
|
+
image_size: int, views=()):
|
|
188
|
+
"""Prompt text, then the skill's own renderings, then the input rows.
|
|
189
|
+
|
|
190
|
+
``views`` holds ``(label, png)`` pairs; each label is numbered separately
|
|
191
|
+
so a two-document gate prompt reads A 01, A 02, B 01 rather than 01..04.
|
|
192
|
+
"""
|
|
193
|
+
attached = REPRESENTATIONS[representation].blocks(rows, tag, image_size)
|
|
194
|
+
if not attached and not views:
|
|
195
|
+
return prompt
|
|
196
|
+
blocks = [{"type": "text", "text": prompt}]
|
|
197
|
+
counts: dict[str, int] = {}
|
|
198
|
+
for label, png in views:
|
|
199
|
+
counts[label] = counts.get(label, 0) + 1
|
|
200
|
+
blocks.append({"type": "text",
|
|
201
|
+
"text": f"{label} {counts[label]:02d}"})
|
|
202
|
+
blocks.append(_png_block(png))
|
|
203
|
+
return blocks + attached
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _inputs(rows: list[dict], representation: str, tag: str = "input") -> str:
|
|
207
|
+
if not REPRESENTATIONS[representation].inline:
|
|
208
|
+
return ""
|
|
209
|
+
serialize = REPRESENTATIONS[representation].serialize
|
|
210
|
+
return "\n\n".join(
|
|
211
|
+
f'<{tag} id="P{number:02d}">\n{serialize(row)}\n</{tag}>'
|
|
212
|
+
for number, row in enumerate(rows, 1))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _revealed(rows: list[dict], predictions: list[dict], representation: str,
|
|
216
|
+
cites_rules: bool = True) -> str:
|
|
217
|
+
"""The labeled feedback block: truth, prediction, citations, evidence."""
|
|
218
|
+
def cited(prediction, attribute: bool):
|
|
219
|
+
if not cites_rules:
|
|
220
|
+
return ""
|
|
221
|
+
joined = ",".join(prediction["rules_used"]) or "none"
|
|
222
|
+
return f' rules="{joined}"' if attribute else f"rules={joined}; "
|
|
223
|
+
|
|
224
|
+
if not REPRESENTATIONS[representation].inline:
|
|
225
|
+
return "\n".join(
|
|
226
|
+
f'P{number:02d}: true={row["label"]}; '
|
|
227
|
+
f'predicted={prediction["pred"]}; '
|
|
228
|
+
f'{cited(prediction, False)}'
|
|
229
|
+
f'evidence={prediction["evidence"]}'
|
|
230
|
+
for number, (row, prediction)
|
|
231
|
+
in enumerate(zip(rows, predictions), 1))
|
|
232
|
+
serialize = REPRESENTATIONS[representation].serialize
|
|
233
|
+
return "\n\n".join(
|
|
234
|
+
f'<example id="P{number:02d}" true="{row["label"]}" '
|
|
235
|
+
f'predicted="{prediction["pred"]}"{cited(prediction, True)}>\n'
|
|
236
|
+
f'{serialize(row)}\n'
|
|
237
|
+
f'<evidence>{prediction["evidence"]}</evidence>\n</example>'
|
|
238
|
+
for number, (row, prediction) in enumerate(zip(rows, predictions), 1))
|
sdm/format.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
format: markdown
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Skill formats
|
|
6
|
+
|
|
7
|
+
Pick a format by editing the `format:` line above. Each section below defines
|
|
8
|
+
one. The `mechanism:` line says which machinery the harness uses:
|
|
9
|
+
|
|
10
|
+
- `edits` numbered rules revised by one bounded atomic diff
|
|
11
|
+
- `document` one text document replaced outright, or kept with NOOP
|
|
12
|
+
- `signal` a source the model writes but never re-reads, only its rendering
|
|
13
|
+
|
|
14
|
+
Sections using `edits` or `document` are pure configuration, so a new
|
|
15
|
+
text-based format needs nothing but another section here. Safety checks stay
|
|
16
|
+
in Python: `validator:` selects them.
|
|
17
|
+
|
|
18
|
+
## markdown
|
|
19
|
+
|
|
20
|
+
mechanism: edits
|
|
21
|
+
max_words: 8000
|
|
22
|
+
max_rules: 100
|
|
23
|
+
|
|
24
|
+
Use one operation per line and never put "|" inside a condition. Rules are a
|
|
25
|
+
rulebook, not an example log: no input IDs, no labels copied from individual
|
|
26
|
+
examples, no memorized values. Prefer revising an existing rule over adding a
|
|
27
|
+
near-duplicate. Write only the predicate: do not start it with IF or end it
|
|
28
|
+
with a prediction. IDs and the reliability counters are maintained by the
|
|
29
|
+
harness; do not write them.
|
|
30
|
+
|
|
31
|
+
## html
|
|
32
|
+
|
|
33
|
+
mechanism: document
|
|
34
|
+
validator: html
|
|
35
|
+
block: skill_html
|
|
36
|
+
max_words: 8000
|
|
37
|
+
max_chars: 30000
|
|
38
|
+
|
|
39
|
+
One standalone static page, ending with `</html>`. No scripts, no frames, no
|
|
40
|
+
forms, no event handler attributes, and no external references of any kind.
|
|
41
|
+
Inline SVG is encouraged where a sketch beats a sentence: small labelled
|
|
42
|
+
prototype drawings per class, a workflow diagram for the reading procedure,
|
|
43
|
+
and contrast pairs for classes that get confused. Track recurring mistakes in
|
|
44
|
+
an error-log section so the same confusion is not repeated.
|
|
45
|
+
|
|
46
|
+
## image
|
|
47
|
+
|
|
48
|
+
mechanism: document
|
|
49
|
+
validator: svg
|
|
50
|
+
render: svg
|
|
51
|
+
block: skill_svg
|
|
52
|
+
max_words: 2000
|
|
53
|
+
max_chars: 20000
|
|
54
|
+
|
|
55
|
+
One standalone `<svg>` element, ending with `</svg>`. This is a diagram you
|
|
56
|
+
will be shown as a picture on every future prediction, so draw the decision
|
|
57
|
+
procedure rather than writing paragraphs: labelled class prototypes, a
|
|
58
|
+
flowchart of the reading procedure, and contrast pairs for confusable classes.
|
|
59
|
+
Text inside the SVG is fine, but anything that only makes sense as prose
|
|
60
|
+
belongs in a caption. No external references.
|
|
61
|
+
|
|
62
|
+
## audio
|
|
63
|
+
|
|
64
|
+
mechanism: signal
|
|
65
|
+
signal: audio
|
|
66
|
+
|
|
67
|
+
Write a synthesis score, one event per line:
|
|
68
|
+
TONE <frequency_hz 50-4000> <seconds 0.05-2>
|
|
69
|
+
REST <seconds 0.05-2>
|
|
70
|
+
Lines starting with # are stripped before rendering and will NOT survive.
|
|
71
|
+
At most 400 events and 30s total.
|
|
72
|
+
|
|
73
|
+
## video
|
|
74
|
+
|
|
75
|
+
mechanism: signal
|
|
76
|
+
signal: video
|
|
77
|
+
|
|
78
|
+
Write a standalone HTML storyboard. Each frame is:
|
|
79
|
+
<section class="frame" data-seconds="N">
|
|
80
|
+
<svg width="640" height="360" viewBox="0 0 640 360"> ... </svg>
|
|
81
|
+
</section>
|
|
82
|
+
with 1 <= N <= 10, at most 12 frames, 60s total. No scripts, no external
|
|
83
|
+
references. Anything you draw or write inside the SVGs survives as pixels;
|
|
84
|
+
nothing outside them does.
|