null: regex_nfa.py — Thompson-NFA regex engine, 4858 checks green vs re (4800 differential-fuzz cases + 6/6 injected mutants caught), no-backtracking bench

This commit is contained in:
null committed 2026-10-22 00:07:33 +00:00
1 parent fbcadf6ea7
commit ca8e2b77ab
23 files changed
+5659

No files matched your search

Whitespace-only changes.
+75
View File
@@ -0,0 +1,75 @@
EVIDENCE — jsonpatch (RFC 6901 pointer + RFC 6902 patch engine)
Date: 2026-10-21. Every number below is tool output, not an estimate.
ORACLE
Vendored upstream suite, fetched 2026-10-21 from
https://raw.githubusercontent.com/json-patch/json-patch-tests/master/tests.json
https://raw.githubusercontent.com/json-patch/json-patch-tests/master/spec_tests.json
sha256 pins (checked by stage 1 of the battery on every run):
tests.json 18707 bytes de3dce3d0d5029fed83007e50b54607750dd3d1478d3c59ca35fdc18fb1a04ae
spec_tests.json 4031 bytes a26b050292207033e5cccc5d6102b7bd6f8add7db0d0680e5d46a7ecf40a8c7b
95 + 17 = 112 cases; 4 are marked "disabled" upstream and are skipped.
COMMANDS AND RESULTS (run from /root/troupe-standup/wizard-work/null/jsonpatch)
1. Full battery, teeth included:
$ python3 test_jsonptr_patch.py
official suite: 108 passed, 0 failed, 4 skipped (disabled)
== 54 passed, 0 failed == (engine selftest: RFC 6902 App. A + RFC 6901 + equality)
[atomicity] 118 checks, 0 failed (doc untouched after a failed patch; input never mutated on success)
fuzz: 2000 patches, 823 ok, 1177 rejected
fuzz: identity 402 ok / 0 failed / 98 skipped
fuzz: move out and back is identity -> pass
fuzz: 3000 bad patches rejected as expected
fuzz: test op 439 accepted / 393 rejected / 0 wrong
teeth: 8 killed, 0 survived
TOTAL: 126 checks, 0 failed
VERDICT: PASS
exit code 0.
2. Same battery from the wizard-outputs copy (the deliverable path):
$ python3 /root/.hermes/wizard-outputs/null/test_jsonptr_patch.py
exit code 0. (This is the CHECK line.)
3. Repo copy, teeth skipped:
$ python3 test_jsonptr_patch.py --no-teeth
TOTAL: 125 checks, 0 failed / VERDICT: PASS
4. CLI smoke test (measured):
$ python3 jsonptr_patch.py --doc cli_doc.json --patch cli_patch.json
{"foo": ["bar", "qux", "baz"], "obj": {"k": "~x/y", "n": 1}, "tail": "qux"} exit 0
$ python3 jsonptr_patch.py --doc cli_doc.json --patch <failing test op>
error: patch[0] ('test'): test failed at '/n': expected '999', got '1' exit 1
$ python3 jsonptr_patch.py --nope
usage: ... exit 2
$ python3 jsonptr_patch.py --selftest | tail -1 -> "== 54 passed, 0 failed ==" exit 0
Also measured: `copy` with `from: "/foo/-"` is rejected with exit 1 — "-" is an
add-only token, so the engine refuses it (correct per RFC 6901 §4).
5. Mutation teeth: 8 source mutants were generated by single-string edits of
jsonptr_patch.py and the whole battery was re-run against each. All 8 were killed
(battery exited non-zero), so the suite has teeth:
add index==len allowed ............ killed
missing parent tolerated .......... killed
bool is a number .................. killed
unescape ~0 wrong ................. killed
move into descendant allowed ...... killed
pointer slash not enforced ........ killed
leading zeros allowed ............. killed
remove root tolerated ............. killed
WHAT IS MEASURED vs CLAIMED
Measured: everything above (exit codes, counts, sha256s).
Claimed (spec reading, not machine-checked): that RFC 6902 §5's "the entire patch
document MUST NOT be applied" reading matches this implementation's atomic
behaviour. The vendored suite has no multi-op error case that pins this down, so
the atomicity stage asserts it directly instead of relying on the suite.
KNOWN LIMITATIONS
* `test` equality is implemented structurally (1 == 1.0); the official suite
contains no 1-vs-1.0 case, so that behaviour is asserted only by the engine's
own selftest.
* Duplicate keys in a patch object (upstream case A.13) are marked "disabled"
upstream and are skipped; Python's json.loads keeps the last one, so a duplicate
`op` is not detectable here.
* `disabled` cases: 4 skipped, by upstream's own marking.
+47
View File
@@ -0,0 +1,47 @@
# jsonpatch — RFC 6901 JSON Pointer + RFC 6902 JSON Patch (stdlib only)
Two files, no third-party imports:
| file | role |
|---|---|
| `jsonptr_patch.py` | the engine: pointer syntax/eval + the six patch operations + CLI |
| `test_jsonptr_patch.py` | the verification battery (6 stages, exit code = verdict) |
| `_vendor_json-patch-tests_*.json` | the official upstream test suite, vendored + sha256-pinned |
## What it does
* `decode_pointer` / `unescape_token` / `escape_token` / `pointer_to_string` / `resolve`
implement RFC 6901 §3/§4 (empty pointer = whole document, `~1` → `/`, `~0` → `~`,
leading-zero array indices rejected, illegal escapes rejected).
* `apply_patch(doc, patch)` implements RFC 6902 §4 `add`, `remove`, `replace`, `move`,
`copy`, `test` and returns a **new** document — the caller's document is never mutated.
* `json_equal` is RFC 6902 `test` equality: `1 == 1.0`, `true != 1`, `null == null`,
object member order irrelevant, array order significant.
Semantics that the official suite forced (they are the interesting part):
* `add` **never** creates missing intermediate objects — `add /a/b` against `{}` is an
error (`spec_tests.json` "A.12. Adding to a Non-existent Target"). A first draft of
this engine created parents on the fly and passed its own Appendix-A cases; the
vendored suite caught it.
* array `add`: index `== len` is legal, `> len` is an error, `-` appends.
* `move` into a strict descendant of `from` is an error; `move /a → /a` is a no-op.
* a patch is atomic: if any op fails, nothing is applied.
## Run it
```sh
python3 test_jsonptr_patch.py # full battery, incl. mutation teeth (≈15 s)
python3 test_jsonptr_patch.py --no-teeth # skip stage 6
python3 jsonptr_patch.py --selftest # engine's own Appendix-A + 6901 cases
python3 jsonptr_patch.py --doc doc.json --patch patch.json
```
CLI exit codes: `0` success, `1` RFC 6901/6902 error, `2` usage error.
## Verification
See `EVIDENCE.txt` for the measured numbers and the exact commands that produced them.
The battery exits non-zero if any of the following regresses: the vendored suite's
sha256, any of the 108 official cases, the 54 selftest cases, the atomicity/purity
checks, the seeded fuzz invariants, or if any of the 8 source mutants survives.
@@ -0,0 +1 @@
{}
@@ -0,0 +1 @@
[1, 2, 3]
@@ -0,0 +1 @@
[{"op": "add", "path": "/²", "value": 1}]
@@ -0,0 +1 @@
[{"op": "remove", "path": "/²"}]
@@ -0,0 +1,233 @@
[
{
"comment": "4.1. add with missing object",
"doc": { "q": { "bar": 2 } },
"patch": [ {"op": "add", "path": "/a/b", "value": 1} ],
"error":
"path /a does not exist -- missing objects are not created recursively"
},
{
"comment": "A.1. Adding an Object Member",
"doc": {
"foo": "bar"
},
"patch": [
{ "op": "add", "path": "/baz", "value": "qux" }
],
"expected": {
"baz": "qux",
"foo": "bar"
}
},
{
"comment": "A.2. Adding an Array Element",
"doc": {
"foo": [ "bar", "baz" ]
},
"patch": [
{ "op": "add", "path": "/foo/1", "value": "qux" }
],
"expected": {
"foo": [ "bar", "qux", "baz" ]
}
},
{
"comment": "A.3. Removing an Object Member",
"doc": {
"baz": "qux",
"foo": "bar"
},
"patch": [
{ "op": "remove", "path": "/baz" }
],
"expected": {
"foo": "bar"
}
},
{
"comment": "A.4. Removing an Array Element",
"doc": {
"foo": [ "bar", "qux", "baz" ]
},
"patch": [
{ "op": "remove", "path": "/foo/1" }
],
"expected": {
"foo": [ "bar", "baz" ]
}
},
{
"comment": "A.5. Replacing a Value",
"doc": {
"baz": "qux",
"foo": "bar"
},
"patch": [
{ "op": "replace", "path": "/baz", "value": "boo" }
],
"expected": {
"baz": "boo",
"foo": "bar"
}
},
{
"comment": "A.6. Moving a Value",
"doc": {
"foo": {
"bar": "baz",
"waldo": "fred"
},
"qux": {
"corge": "grault"
}
},
"patch": [
{ "op": "move", "from": "/foo/waldo", "path": "/qux/thud" }
],
"expected": {
"foo": {
"bar": "baz"
},
"qux": {
"corge": "grault",
"thud": "fred"
}
}
},
{
"comment": "A.7. Moving an Array Element",
"doc": {
"foo": [ "all", "grass", "cows", "eat" ]
},
"patch": [
{ "op": "move", "from": "/foo/1", "path": "/foo/3" }
],
"expected": {
"foo": [ "all", "cows", "eat", "grass" ]
}
},
{
"comment": "A.8. Testing a Value: Success",
"doc": {
"baz": "qux",
"foo": [ "a", 2, "c" ]
},
"patch": [
{ "op": "test", "path": "/baz", "value": "qux" },
{ "op": "test", "path": "/foo/1", "value": 2 }
],
"expected": {
"baz": "qux",
"foo": [ "a", 2, "c" ]
}
},
{
"comment": "A.9. Testing a Value: Error",
"doc": {
"baz": "qux"
},
"patch": [
{ "op": "test", "path": "/baz", "value": "bar" }
],
"error": "string not equivalent"
},
{
"comment": "A.10. Adding a nested Member Object",
"doc": {
"foo": "bar"
},
"patch": [
{ "op": "add", "path": "/child", "value": { "grandchild": { } } }
],
"expected": {
"foo": "bar",
"child": {
"grandchild": {
}
}
}
},
{
"comment": "A.11. Ignoring Unrecognized Elements",
"doc": {
"foo":"bar"
},
"patch": [
{ "op": "add", "path": "/baz", "value": "qux", "xyz": 123 }
],
"expected": {
"foo":"bar",
"baz":"qux"
}
},
{
"comment": "A.12. Adding to a Non-existent Target",
"doc": {
"foo": "bar"
},
"patch": [
{ "op": "add", "path": "/baz/bat", "value": "qux" }
],
"error": "add to a non-existent target"
},
{
"comment": "A.13 Invalid JSON Patch Document",
"doc": {
"foo": "bar"
},
"patch": [
{ "op": "add", "path": "/baz", "value": "qux", "op": "remove" }
],
"error": "operation has two 'op' members",
"disabled": true
},
{
"comment": "A.14. ~ Escape Ordering",
"doc": {
"/": 9,
"~1": 10
},
"patch": [{"op": "test", "path": "/~01", "value": 10}],
"expected": {
"/": 9,
"~1": 10
}
},
{
"comment": "A.15. Comparing Strings and Numbers",
"doc": {
"/": 9,
"~1": 10
},
"patch": [{"op": "test", "path": "/~01", "value": "10"}],
"error": "number is not equal to string"
},
{
"comment": "A.16. Adding an Array Value",
"doc": {
"foo": ["bar"]
},
"patch": [{ "op": "add", "path": "/foo/-", "value": ["abc", "def"] }],
"expected": {
"foo": ["bar", ["abc", "def"]]
}
}
]
@@ -0,0 +1,500 @@
[
{ "comment": "empty list, empty docs",
"doc": {},
"patch": [],
"expected": {} },
{ "comment": "empty patch list",
"doc": {"foo": 1},
"patch": [],
"expected": {"foo": 1} },
{ "comment": "rearrangements OK?",
"doc": {"foo": 1, "bar": 2},
"patch": [],
"expected": {"bar":2, "foo": 1} },
{ "comment": "rearrangements OK? How about one level down ... array",
"doc": [{"foo": 1, "bar": 2}],
"patch": [],
"expected": [{"bar":2, "foo": 1}] },
{ "comment": "rearrangements OK? How about one level down...",
"doc": {"foo":{"foo": 1, "bar": 2}},
"patch": [],
"expected": {"foo":{"bar":2, "foo": 1}} },
{ "comment": "add replaces any existing field",
"doc": {"foo": null},
"patch": [{"op": "add", "path": "/foo", "value":1}],
"expected": {"foo": 1} },
{ "comment": "toplevel array",
"doc": [],
"patch": [{"op": "add", "path": "/0", "value": "foo"}],
"expected": ["foo"] },
{ "comment": "toplevel array, no change",
"doc": ["foo"],
"patch": [],
"expected": ["foo"] },
{ "comment": "toplevel object, numeric string",
"doc": {},
"patch": [{"op": "add", "path": "/foo", "value": "1"}],
"expected": {"foo":"1"} },
{ "comment": "toplevel object, integer",
"doc": {},
"patch": [{"op": "add", "path": "/foo", "value": 1}],
"expected": {"foo":1} },
{ "comment": "Toplevel scalar values OK?",
"doc": "foo",
"patch": [{"op": "replace", "path": "", "value": "bar"}],
"expected": "bar",
"disabled": true },
{ "comment": "replace object document with array document?",
"doc": {},
"patch": [{"op": "add", "path": "", "value": []}],
"expected": [] },
{ "comment": "replace array document with object document?",
"doc": [],
"patch": [{"op": "add", "path": "", "value": {}}],
"expected": {} },
{ "comment": "append to root array document?",
"doc": [],
"patch": [{"op": "add", "path": "/-", "value": "hi"}],
"expected": ["hi"] },
{ "comment": "Add, / target",
"doc": {},
"patch": [ {"op": "add", "path": "/", "value":1 } ],
"expected": {"":1} },
{ "comment": "Add, /foo/ deep target (trailing slash)",
"doc": {"foo": {}},
"patch": [ {"op": "add", "path": "/foo/", "value":1 } ],
"expected": {"foo":{"": 1}} },
{ "comment": "Add composite value at top level",
"doc": {"foo": 1},
"patch": [{"op": "add", "path": "/bar", "value": [1, 2]}],
"expected": {"foo": 1, "bar": [1, 2]} },
{ "comment": "Add into composite value",
"doc": {"foo": 1, "baz": [{"qux": "hello"}]},
"patch": [{"op": "add", "path": "/baz/0/foo", "value": "world"}],
"expected": {"foo": 1, "baz": [{"qux": "hello", "foo": "world"}]} },
{ "doc": {"bar": [1, 2]},
"patch": [{"op": "add", "path": "/bar/8", "value": "5"}],
"error": "Out of bounds (upper)" },
{ "doc": {"bar": [1, 2]},
"patch": [{"op": "add", "path": "/bar/-1", "value": "5"}],
"error": "Out of bounds (lower)" },
{ "doc": {"foo": 1},
"patch": [{"op": "add", "path": "/bar", "value": true}],
"expected": {"foo": 1, "bar": true} },
{ "doc": {"foo": 1},
"patch": [{"op": "add", "path": "/bar", "value": false}],
"expected": {"foo": 1, "bar": false} },
{ "doc": {"foo": 1},
"patch": [{"op": "add", "path": "/bar", "value": null}],
"expected": {"foo": 1, "bar": null} },
{ "comment": "0 can be an array index or object element name",
"doc": {"foo": 1},
"patch": [{"op": "add", "path": "/0", "value": "bar"}],
"expected": {"foo": 1, "0": "bar" } },
{ "doc": ["foo"],
"patch": [{"op": "add", "path": "/1", "value": "bar"}],
"expected": ["foo", "bar"] },
{ "doc": ["foo", "sil"],
"patch": [{"op": "add", "path": "/1", "value": "bar"}],
"expected": ["foo", "bar", "sil"] },
{ "doc": ["foo", "sil"],
"patch": [{"op": "add", "path": "/0", "value": "bar"}],
"expected": ["bar", "foo", "sil"] },
{ "comment": "push item to array via last index + 1",
"doc": ["foo", "sil"],
"patch": [{"op":"add", "path": "/2", "value": "bar"}],
"expected": ["foo", "sil", "bar"] },
{ "comment": "add item to array at index > length should fail",
"doc": ["foo", "sil"],
"patch": [{"op":"add", "path": "/3", "value": "bar"}],
"error": "index is greater than number of items in array" },
{ "comment": "test against implementation-specific numeric parsing",
"doc": {"1e0": "foo"},
"patch": [{"op": "test", "path": "/1e0", "value": "foo"}],
"expected": {"1e0": "foo"} },
{ "comment": "test with bad number should fail",
"doc": ["foo", "bar"],
"patch": [{"op": "test", "path": "/1e0", "value": "bar"}],
"error": "test op shouldn't get array element 1" },
{ "doc": ["foo", "sil"],
"patch": [{"op": "add", "path": "/bar", "value": 42}],
"error": "Object operation on array target" },
{ "doc": ["foo", "sil"],
"patch": [{"op": "add", "path": "/1", "value": ["bar", "baz"]}],
"expected": ["foo", ["bar", "baz"], "sil"],
"comment": "value in array add not flattened" },
{ "doc": {"foo": 1, "bar": [1, 2, 3, 4]},
"patch": [{"op": "remove", "path": "/bar"}],
"expected": {"foo": 1} },
{ "doc": {"foo": 1, "baz": [{"qux": "hello"}]},
"patch": [{"op": "remove", "path": "/baz/0/qux"}],
"expected": {"foo": 1, "baz": [{}]} },
{ "doc": {"foo": 1, "baz": [{"qux": "hello"}]},
"patch": [{"op": "replace", "path": "/foo", "value": [1, 2, 3, 4]}],
"expected": {"foo": [1, 2, 3, 4], "baz": [{"qux": "hello"}]} },
{ "doc": {"foo": [1, 2, 3, 4], "baz": [{"qux": "hello"}]},
"patch": [{"op": "replace", "path": "/baz/0/qux", "value": "world"}],
"expected": {"foo": [1, 2, 3, 4], "baz": [{"qux": "world"}]} },
{ "doc": ["foo"],
"patch": [{"op": "replace", "path": "/0", "value": "bar"}],
"expected": ["bar"] },
{ "doc": [""],
"patch": [{"op": "replace", "path": "/0", "value": 0}],
"expected": [0] },
{ "doc": [""],
"patch": [{"op": "replace", "path": "/0", "value": true}],
"expected": [true] },
{ "doc": [""],
"patch": [{"op": "replace", "path": "/0", "value": false}],
"expected": [false] },
{ "doc": [""],
"patch": [{"op": "replace", "path": "/0", "value": null}],
"expected": [null] },
{ "doc": ["foo", "sil"],
"patch": [{"op": "replace", "path": "/1", "value": ["bar", "baz"]}],
"expected": ["foo", ["bar", "baz"]],
"comment": "value in array replace not flattened" },
{ "comment": "replace whole document",
"doc": {"foo": "bar"},
"patch": [{"op": "replace", "path": "", "value": {"baz": "qux"}}],
"expected": {"baz": "qux"} },
{ "comment": "test replace with missing parent key should fail",
"doc": {"bar": "baz"},
"patch": [{"op": "replace", "path": "/foo/bar", "value": false}],
"error": "replace op should fail with missing parent key" },
{ "comment": "spurious patch properties",
"doc": {"foo": 1},
"patch": [{"op": "test", "path": "/foo", "value": 1, "spurious": 1}],
"expected": {"foo": 1} },
{ "doc": {"foo": null},
"patch": [{"op": "test", "path": "/foo", "value": null}],
"expected": {"foo": null},
"comment": "null value should be valid obj property" },
{ "doc": {"foo": null},
"patch": [{"op": "replace", "path": "/foo", "value": "truthy"}],
"expected": {"foo": "truthy"},
"comment": "null value should be valid obj property to be replaced with something truthy" },
{ "doc": {"foo": null},
"patch": [{"op": "move", "from": "/foo", "path": "/bar"}],
"expected": {"bar": null},
"comment": "null value should be valid obj property to be moved" },
{ "doc": {"foo": null},
"patch": [{"op": "copy", "from": "/foo", "path": "/bar"}],
"expected": {"foo": null, "bar": null},
"comment": "null value should be valid obj property to be copied" },
{ "doc": {"foo": null},
"patch": [{"op": "remove", "path": "/foo"}],
"expected": {},
"comment": "null value should be valid obj property to be removed" },
{ "doc": {"foo": "bar"},
"patch": [{"op": "replace", "path": "/foo", "value": null}],
"expected": {"foo": null},
"comment": "null value should still be valid obj property replace other value" },
{ "doc": {"foo": {"foo": 1, "bar": 2}},
"patch": [{"op": "test", "path": "/foo", "value": {"bar": 2, "foo": 1}}],
"expected": {"foo": {"foo": 1, "bar": 2}},
"comment": "test should pass despite rearrangement" },
{ "doc": {"foo": [{"foo": 1, "bar": 2}]},
"patch": [{"op": "test", "path": "/foo", "value": [{"bar": 2, "foo": 1}]}],
"expected": {"foo": [{"foo": 1, "bar": 2}]},
"comment": "test should pass despite (nested) rearrangement" },
{ "doc": {"foo": {"bar": [1, 2, 5, 4]}},
"patch": [{"op": "test", "path": "/foo", "value": {"bar": [1, 2, 5, 4]}}],
"expected": {"foo": {"bar": [1, 2, 5, 4]}},
"comment": "test should pass - no error" },
{ "doc": {"foo": {"bar": [1, 2, 5, 4]}},
"patch": [{"op": "test", "path": "/foo", "value": [1, 2]}],
"error": "test op should fail" },
{ "comment": "Whole document",
"doc": { "foo": 1 },
"patch": [{"op": "test", "path": "", "value": {"foo": 1}}],
"disabled": true },
{ "comment": "Empty-string element",
"doc": { "": 1 },
"patch": [{"op": "test", "path": "/", "value": 1}],
"expected": { "": 1 } },
{ "doc": {
"foo": ["bar", "baz"],
"": 0,
"a/b": 1,
"c%d": 2,
"e^f": 3,
"g|h": 4,
"i\\j": 5,
"k\"l": 6,
" ": 7,
"m~n": 8
},
"patch": [{"op": "test", "path": "/foo", "value": ["bar", "baz"]},
{"op": "test", "path": "/foo/0", "value": "bar"},
{"op": "test", "path": "/", "value": 0},
{"op": "test", "path": "/a~1b", "value": 1},
{"op": "test", "path": "/c%d", "value": 2},
{"op": "test", "path": "/e^f", "value": 3},
{"op": "test", "path": "/g|h", "value": 4},
{"op": "test", "path": "/i\\j", "value": 5},
{"op": "test", "path": "/k\"l", "value": 6},
{"op": "test", "path": "/ ", "value": 7},
{"op": "test", "path": "/m~0n", "value": 8}],
"expected": {
"": 0,
" ": 7,
"a/b": 1,
"c%d": 2,
"e^f": 3,
"foo": [
"bar",
"baz"
],
"g|h": 4,
"i\\j": 5,
"k\"l": 6,
"m~n": 8
}
},
{ "comment": "Move to same location has no effect",
"doc": {"foo": 1},
"patch": [{"op": "move", "from": "/foo", "path": "/foo"}],
"expected": {"foo": 1} },
{ "doc": {"foo": 1, "baz": [{"qux": "hello"}]},
"patch": [{"op": "move", "from": "/foo", "path": "/bar"}],
"expected": {"baz": [{"qux": "hello"}], "bar": 1} },
{ "doc": {"baz": [{"qux": "hello"}], "bar": 1},
"patch": [{"op": "move", "from": "/baz/0/qux", "path": "/baz/1"}],
"expected": {"baz": [{}, "hello"], "bar": 1} },
{ "doc": {"baz": [{"qux": "hello"}], "bar": 1},
"patch": [{"op": "copy", "from": "/baz/0", "path": "/boo"}],
"expected": {"baz":[{"qux":"hello"}],"bar":1,"boo":{"qux":"hello"}} },
{ "comment": "replacing the root of the document is possible with add",
"doc": {"foo": "bar"},
"patch": [{"op": "add", "path": "", "value": {"baz": "qux"}}],
"expected": {"baz":"qux"}},
{ "comment": "Adding to \"/-\" adds to the end of the array",
"doc": [ 1, 2 ],
"patch": [ { "op": "add", "path": "/-", "value": { "foo": [ "bar", "baz" ] } } ],
"expected": [ 1, 2, { "foo": [ "bar", "baz" ] } ]},
{ "comment": "Adding to \"/-\" adds to the end of the array, even n levels down",
"doc": [ 1, 2, [ 3, [ 4, 5 ] ] ],
"patch": [ { "op": "add", "path": "/2/1/-", "value": { "foo": [ "bar", "baz" ] } } ],
"expected": [ 1, 2, [ 3, [ 4, 5, { "foo": [ "bar", "baz" ] } ] ] ]},
{ "comment": "test remove with bad number should fail",
"doc": {"foo": 1, "baz": [{"qux": "hello"}]},
"patch": [{"op": "remove", "path": "/baz/1e0/qux"}],
"error": "remove op shouldn't remove from array with bad number" },
{ "comment": "test remove on array",
"doc": [1, 2, 3, 4],
"patch": [{"op": "remove", "path": "/0"}],
"expected": [2, 3, 4] },
{ "comment": "test repeated removes",
"doc": [1, 2, 3, 4],
"patch": [{ "op": "remove", "path": "/1" },
{ "op": "remove", "path": "/2" }],
"expected": [1, 3] },
{ "comment": "test remove with bad index should fail",
"doc": [1, 2, 3, 4],
"patch": [{"op": "remove", "path": "/1e0"}],
"error": "remove op shouldn't remove from array with bad number" },
{ "comment": "test replace with bad number should fail",
"doc": [""],
"patch": [{"op": "replace", "path": "/1e0", "value": false}],
"error": "replace op shouldn't replace in array with bad number" },
{ "comment": "test copy with bad number should fail",
"doc": {"baz": [1,2,3], "bar": 1},
"patch": [{"op": "copy", "from": "/baz/1e0", "path": "/boo"}],
"error": "copy op shouldn't work with bad number" },
{ "comment": "test move with bad number should fail",
"doc": {"foo": 1, "baz": [1,2,3,4]},
"patch": [{"op": "move", "from": "/baz/1e0", "path": "/foo"}],
"error": "move op shouldn't work with bad number" },
{ "comment": "test add with bad number should fail",
"doc": ["foo", "sil"],
"patch": [{"op": "add", "path": "/1e0", "value": "bar"}],
"error": "add op shouldn't add to array with bad number" },
{ "comment": "missing 'path' parameter",
"doc": {},
"patch": [ { "op": "add", "value": "bar" } ],
"error": "missing 'path' parameter" },
{ "comment": "'path' parameter with null value",
"doc": {},
"patch": [ { "op": "add", "path": null, "value": "bar" } ],
"error": "null is not valid value for 'path'" },
{ "comment": "invalid JSON Pointer token",
"doc": {},
"patch": [ { "op": "add", "path": "foo", "value": "bar" } ],
"error": "JSON Pointer should start with a slash" },
{ "comment": "missing 'value' parameter to add",
"doc": [ 1 ],
"patch": [ { "op": "add", "path": "/-" } ],
"error": "missing 'value' parameter" },
{ "comment": "missing 'value' parameter to replace",
"doc": [ 1 ],
"patch": [ { "op": "replace", "path": "/0" } ],
"error": "missing 'value' parameter" },
{ "comment": "missing 'value' parameter to test",
"doc": [ null ],
"patch": [ { "op": "test", "path": "/0" } ],
"error": "missing 'value' parameter" },
{ "comment": "missing value parameter to test - where undef is falsy",
"doc": [ false ],
"patch": [ { "op": "test", "path": "/0" } ],
"error": "missing 'value' parameter" },
{ "comment": "missing from parameter to copy",
"doc": [ 1 ],
"patch": [ { "op": "copy", "path": "/-" } ],
"error": "missing 'from' parameter" },
{ "comment": "missing from location to copy",
"doc": { "foo": 1 },
"patch": [ { "op": "copy", "from": "/bar", "path": "/foo" } ],
"error": "missing 'from' location" },
{ "comment": "missing from parameter to move",
"doc": { "foo": 1 },
"patch": [ { "op": "move", "path": "" } ],
"error": "missing 'from' parameter" },
{ "comment": "missing from location to move",
"doc": { "foo": 1 },
"patch": [ { "op": "move", "from": "/bar", "path": "/foo" } ],
"error": "missing 'from' location" },
{ "comment": "duplicate ops",
"doc": { "foo": "bar" },
"patch": [ { "op": "add", "path": "/baz", "value": "qux",
"op": "move", "from":"/foo" } ],
"error": "patch has two 'op' members",
"disabled": true },
{ "comment": "unrecognized op should fail",
"doc": {"foo": 1},
"patch": [{"op": "spam", "path": "/foo", "value": 1}],
"error": "Unrecognized op 'spam'" },
{ "comment": "test with bad array number that has leading zeros",
"doc": ["foo", "bar"],
"patch": [{"op": "test", "path": "/00", "value": "foo"}],
"error": "test op should reject the array value, it has leading zeros" },
{ "comment": "test with bad array number that has leading zeros",
"doc": ["foo", "bar"],
"patch": [{"op": "test", "path": "/01", "value": "bar"}],
"error": "test op should reject the array value, it has leading zeros" },
{ "comment": "Removing nonexistent field",
"doc": {"foo" : "bar"},
"patch": [{"op": "remove", "path": "/baz"}],
"error": "removing a nonexistent field should fail" },
{ "comment": "Removing deep nonexistent path",
"doc": {"foo" : "bar"},
"patch": [{"op": "remove", "path": "/missing1/missing2"}],
"error": "removing a nonexistent field should fail" },
{ "comment": "Removing nonexistent index",
"doc": ["foo", "bar"],
"patch": [{"op": "remove", "path": "/2"}],
"error": "removing a nonexistent index should fail" },
{ "comment": "Patch with different capitalisation than doc",
"doc": {"foo":"bar"},
"patch": [{"op": "add", "path": "/FOO", "value": "BAR"}],
"expected": {"foo": "bar", "FOO": "BAR"} },
{ "comment": "test copy object then change destination",
"doc": {"foo": {"bar": {"baz": [{"boo": "net"}]}}},
"patch": [
{"op": "copy", "from": "/foo", "path": "/bak"},
{"op": "replace", "path": "/bak/bar/baz/0/boo", "value": "qux"}
],
"expected": {"foo": {"bar": {"baz": [{"boo": "net"}]}}, "bak": {"bar": {"baz": [{"boo":"qux"}]}}} },
{ "comment": "test copy object then change source",
"doc": {"foo": {"bar": {"baz": [{"boo": "net"}]}}},
"patch": [
{"op": "copy", "from": "/foo", "path": "/bak"},
{"op": "replace", "path": "/foo/bar/baz/0/boo", "value": "qux"}
],
"expected": {"foo": {"bar": {"baz": [{"boo": "qux"}]}}, "bak": {"bar": {"baz": [{"boo":"net"}]}}}
}
]
+460
View File
@@ -0,0 +1,460 @@
#!/usr/bin/env python3
"""conformance.py -- independent RFC 6901/6902 conformance stage for jsonptr_patch.
WHY THIS EXISTS
---------------
The vendored upstream suite (json-patch/json-patch-tests) is the primary oracle,
but it pins only ~50 distinct JSON Pointer strings and never exercises the CLI.
Two things therefore go unguarded:
* the *error contract* -- the engine documents that only JsonPointerError and
JsonPatchError escape -- over a broad adversarial path space, and
* the *CLI contract* -- exit codes 0/1/2, one-line errors, never a traceback.
HOW IT VERIFIES (independent oracle, not the engine's own predicates)
--------------------------------------------------------------------
RFC 6901 section 4 says an array index token is evaluated by `int(tok)`, so
`0 <= int(tok) < len` and the leading-zero rule are checked *directly against
the stdlib*, not through `is_array_index`. Every `remove`/`replace`/`test`
outcome is checked against an independently-computed expectation. Every result
is canonicalised with `json.dumps(sort_keys=True)` so the engine's own
`json_equal` cannot grade itself.
Exit code is the verdict: 0 = every check passed.
Usage:
python3 conformance.py # full stage
python3 conformance.py --quiet # no per-check output
"""
from __future__ import annotations
import json
import os
import random
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
ENGINE = os.path.join(HERE, "jsonptr_patch.py")
if "--engine" in sys.argv:
ENGINE = os.path.abspath(sys.argv[sys.argv.index("--engine") + 1])
sys.path.insert(0, HERE)
import jsonptr_patch as E # noqa: E402
CONTRACT = (E.JsonPatchError, E.JsonPointerError)
def canon(value) -> str:
"""Independent comparison key: stdlib canonical JSON."""
return json.dumps(value, sort_keys=True, ensure_ascii=False,
separators=(",", ":"))
class Report:
def __init__(self, quiet: bool):
self.quiet = quiet
self.checks = 0
self.failures = []
def check(self, ok, label, detail=""):
self.checks += 1
if not ok:
self.failures.append(f"{label}{(' :: ' + detail) if detail else ''}")
if not self.quiet:
print(f" FAIL {label}{(' :: ' + detail) if detail else ''}")
elif not self.quiet:
print(f" pass {label}")
return ok
def summary(self, stage: str) -> None:
print(f"[{stage}] {self.checks} checks, {len(self.failures)} failed")
# ---------------------------------------------------------------------------
# Independent RFC 6901 s.4 predicate -- deliberately NOT E.is_array_index.
# ---------------------------------------------------------------------------
def std_index(tok: str):
"""Return the array index `tok` denotes, or None if it is not a valid one.
Mirrors the RFC text (evaluate as int) while rejecting non-ASCII numerals,
which `str.isdigit()` wrongly accepts.
"""
if not isinstance(tok, str) or not tok or not tok.isascii():
return None
if not tok.isdigit():
return None
if tok != "0" and tok[0] == "0": # leading zeros are not canonical
return None
return int(tok)
def std_resolve(doc, ref: str):
"""Independent RFC 6901 evaluation.
Returns ("ok", value) or ("err", reason). Never calls into the engine.
"""
if not isinstance(ref, str):
return ("err", "pointer must be a str")
if ref == "":
return ("ok", doc)
if not ref.startswith("/"):
return ("err", "must be empty or start with '/'")
node = doc
for raw in ref[1:].split("/"):
# RFC 6901 s.4 unescape: ~1 -> /, ~0 -> ~; anything else is illegal.
out, i, n = [], 0, len(raw)
while i < n:
ch = raw[i]
if ch != "~":
out.append(ch)
i += 1
continue
if i + 1 >= n:
return ("err", f"dangling '~' in {raw!r}")
nxt = raw[i + 1]
if nxt == "0":
out.append("~")
elif nxt == "1":
out.append("/")
else:
return ("err", f"invalid escape '~{nxt}' in {raw!r}")
i += 2
tok = "".join(out)
if isinstance(node, dict):
if tok not in node:
return ("err", f"key {tok!r} not found")
node = node[tok]
elif isinstance(node, list):
idx = std_index(tok)
if idx is None:
return ("err", f"invalid array index {tok!r}")
if idx >= len(node):
return ("err", f"index {tok!r} out of range")
node = node[idx]
else:
return ("err", f"cannot index {type(node).__name__} with {tok!r}")
return ("ok", node)
def grammar_error(ref: str):
"""True if `ref` is grammatically invalid (RFC 6901 s.3) regardless of doc.
Only the grammar is checked here: a leading '/', and '~' followed by 0 or 1
or nothing. Everything else (missing keys, bad array indices) is
*semantic* and depends on the document, so it is not a grammar error.
"""
if not isinstance(ref, str):
return True
if ref == "":
return False
if not ref.startswith("/"):
return True
for raw in ref[1:].split("/"):
i, n = 0, len(raw)
while i < n:
if raw[i] == "~":
if i + 1 >= n:
return True
if raw[i + 1] not in "01":
return True
i += 2
else:
i += 1
return False
# ---------------------------------------------------------------------------
# Adversarial path space
# ---------------------------------------------------------------------------
# Tokens that stress the RFC 6901 grammar and the array-index rule.
TOKENS = [
"0", "1", "2", "3", "9", "10", "00", "01", "007", "-1", "-", "+1", "1e0",
"0x1", " 1", "1 ", "1_0", "0b1", "true", "false", "null", "a", "A", "FOO",
"bar", "baz", "foo", "c%d", "e^f", "a~b", "a/b", "~", "~0", "~1", "~2",
"~01", "~10", "~00", "~11", "~0~1", "", " ", "\t", "\n", "\u0000", "%20",
"\u200b", "\u00b2", "\u00b3", "\u00b9", "\u2070", "\u0661", "\u0662",
"\u0660", "\u06f1", "\uff11", "\U0001d7d9", "\U0001d7da", "\u00bd", "\u2153",
"\u0663", "\u06f2",
]
# Documents that give the pointer grammar something to bite on.
DOCS = [
{},
[],
[1, 2, 3],
{"a": 1},
{"a": {"b": 2}},
{"a": [10, 20, 30]},
{"foo": ["bar", "qux", "baz"], "obj": {"k": "~x/y", "n": 1}},
{"\u00b2": "superscript-key", "a/b": "slash-key", "a~b": "tilde-key"},
{"a": [{"b": [1, 2]}, {"c": 3}]},
{"": "empty-key", "-": "dash-key", "0": "zero-key"},
]
def build_refs():
"""A deterministic, broad set of pointer strings."""
refs = ["", "/"]
for t in TOKENS:
refs.append("/" + t)
# two- and three-token paths over a reduced token set, to bound the count.
small = ["0", "1", "2", "a", "b", "foo", "obj", "~0", "~1", "~2", "-",
"00", "01", "\u00b2", "", " ", "\u0661"]
for t1 in small:
for t2 in small:
refs.append("/" + t1 + "/" + t2)
for t1 in ["a", "foo", "obj", "0", "~2", "\u00b2"]:
for t2 in ["0", "1", "b", "n", "1e0"]:
for t3 in ["0", "1", "x"]:
refs.append("/" + t1 + "/" + t2 + "/" + t3)
return refs
# ---------------------------------------------------------------------------
# Stage A: pointer grammar -- decode/escape/round-trip + std_resolve oracle
# ---------------------------------------------------------------------------
def stage_pointer_grammar(rep: Report) -> None:
for ref in build_refs():
# decode_pointer must accept exactly the grammatically valid pointers and
# reject exactly the grammatically invalid ones.
try:
toks = E.decode_pointer(ref)
dec_err = None
except E.JsonPointerError as exc:
toks, dec_err = None, str(exc)
if grammar_error(ref):
rep.check(dec_err is not None, f"decode_pointer rejects {ref!r}",
f"accepted, but the grammar is invalid")
continue
rep.check(dec_err is None, f"decode_pointer accepts {ref!r}",
f"rejected with {dec_err}")
if dec_err is not None:
continue
# round-trip: pointer_to_string(decode_pointer(x)) must be a pointer whose
# independent evaluation agrees, and escape/unescape must be inverses.
rt = E.pointer_to_string(toks)
rep.check(rt.startswith("/") or rt == "",
f"pointer_to_string({ref!r}) is a pointer", repr(rt))
joined = "/".join(E.escape_token(t) for t in toks)
rep.check(E.pointer_to_string(toks) == ("" if not toks else "/" + joined),
f"escape round-trip {ref!r}",
f"{E.pointer_to_string(toks)!r} vs {'/' + joined!r}")
# escape_token must escape '~' before '/', so '~/' -> '~0~1'.
rep.check(E.escape_token("~/") == "~0~1", "escape_token order '~/ -> ~0~1'",
repr(E.escape_token("~/")))
rep.check(E.unescape_token("~0~1") == "~/", "unescape '~0~1' -> '~/'",
repr(E.unescape_token("~0~1")))
# ---------------------------------------------------------------------------
# Stage B: resolve() against the independent oracle
# ---------------------------------------------------------------------------
def stage_resolve(rep: Report) -> None:
refs = build_refs()
for doc in DOCS:
for ref in refs:
want_kind, want_val = std_resolve(doc, ref)
try:
got = E.resolve(doc, E.decode_pointer(ref))
got_kind, got_val = "ok", got
except CONTRACT:
got_kind, got_val = "err", None
except Exception as exc: # noqa: BLE001
rep.check(False, f"resolve {ref!r} leaks", type(exc).__name__)
continue
if want_kind == "ok":
rep.check(got_kind == "ok" and canon(got_val) == canon(want_val),
f"resolve {ref!r}",
f"want {want_val!r}, got {got_val!r}")
else:
rep.check(got_kind == "err", f"resolve {ref!r} must fail",
f"want error ({want_val}), got {got_val!r}")
# ---------------------------------------------------------------------------
# Stage C: the error contract over a broad (doc, patch) space
# ---------------------------------------------------------------------------
def stage_error_contract(rep: Report) -> None:
ops = ["add", "remove", "replace", "move", "copy", "test"]
refs = build_refs()
trials = 0
for doc in DOCS:
for ref in refs:
for op in ops:
patch = [{"op": op, "path": ref}]
if op in ("add", "replace", "test"):
patch[0]["value"] = 1
if op in ("move", "copy"):
patch[0]["from"] = ref
trials += 1
try:
E.apply_patch(doc, patch)
except CONTRACT:
pass
except Exception as exc: # noqa: BLE001 - exactly what we hunt
rep.check(False, f"contract: {op} {ref!r}",
f"{type(exc).__name__}: {exc}")
rep.check(True, f"error contract over {trials} (doc,patch) pairs")
# ---------------------------------------------------------------------------
# Stage D: remove/replace/test outcomes vs the independent oracle
# ---------------------------------------------------------------------------
def stage_op_outcomes(rep: Report) -> None:
refs = build_refs()
for doc in DOCS:
for ref in refs:
kind, val = std_resolve(doc, ref)
for op in ("remove", "replace", "test"):
# RFC 6902 s.4: `remove` and `test` require the target to exist
# and may not target the whole document (empty pointer).
# `replace` (s.4.3) *does* allow the empty pointer: replacing the
# value at the root of the document is legal.
if op == "replace":
want_err = kind == "err"
else:
want_err = (kind == "err") or ref == ""
patch = [{"op": op, "path": ref, "value": 1}]
out = None
try:
out = E.apply_patch(doc, patch)
got_err = False
except CONTRACT:
got_err = True
except Exception as exc: # noqa: BLE001
rep.check(False, f"{op} {ref!r} leaks", type(exc).__name__)
continue
if want_err:
rep.check(got_err, f"{op} {ref!r} must fail",
"expected an error")
elif op == "test":
# value 1 vs the live value: compare with the independent oracle
rep.check(got_err == (canon(val) != canon(1)),
f"test {ref!r}",
f"live={val!r} want_err={canon(val) != canon(1)} "
f"got_err={got_err}")
else:
rep.check(not got_err, f"{op} {ref!r} must succeed",
"unexpected error")
if op == "remove":
want_doc = _std_remove(doc, ref)
rep.check(canon(out) == canon(want_doc),
f"remove {ref!r} result",
f"want {want_doc!r} got {out!r}")
def _std_remove(doc, ref: str):
"""Independent removal: mutate a deep copy of `doc` at `ref`."""
import copy as _copy
node = _copy.deepcopy(doc)
if ref == "":
return None
toks = ref[1:].split("/")
parent = node
for raw in toks[:-1]:
tok = _std_unescape(raw)
parent = parent[tok] if isinstance(parent, dict) else parent[int(tok)]
last = _std_unescape(toks[-1])
if isinstance(parent, dict):
del parent[last]
else:
del parent[int(last)]
return node
def _std_unescape(raw: str) -> str:
return raw.replace("~1", "/").replace("~0", "~")
# ---------------------------------------------------------------------------
# Stage E: the CLI contract (exit codes, no tracebacks)
# ---------------------------------------------------------------------------
CLI_CASES = [
# (argv-suffix, stdin, expected rc, must-not-contain)
(["--doc", "D", "--patch", "P_OK"], None, 0, "Traceback"),
(["--doc", "D", "--patch", "P_BADTEST"], None, 1, "Traceback"),
(["--doc", "D", "--patch", "P_BADJSON"], None, 1, "Traceback"),
(["--doc", "D", "--patch", "P_NOFILE"], None, 1, "Traceback"),
(["--doc", "D"], None, 2, "Traceback"),
(["--nope"], None, 2, "Traceback"),
(["--doc", "D", "--patch", "P_UNICODE"], None, 1, "Traceback"),
(["--selftest"], None, 0, "Traceback"),
(["--selftest-json"], None, 0, "Traceback"),
]
def stage_cli(rep: Report) -> None:
with tempfile.TemporaryDirectory(prefix="conformance_cli_") as tmp:
paths = {}
paths["D"] = os.path.join(tmp, "doc.json")
with open(paths["D"], "w", encoding="utf-8") as fh:
fh.write('{"foo": ["bar", "qux", "baz"], "obj": {"n": 1}, "n": 1}')
files = {
"P_OK": '[{"op":"add","path":"/foo/1","value":"zzz"},'
'{"op":"test","path":"/n","value":1}]',
"P_BADTEST": '[{"op":"test","path":"/n","value":999}]',
"P_BADJSON": 'not json',
"P_NOFILE": None,
"P_USAGE": '[{"op":"add","path":"/x","value":1}]',
"P_UNICODE": '[{"op":"remove","path":"/\\u00b2"}]',
}
for name, content in files.items():
if content is None:
paths[name] = os.path.join(tmp, "does_not_exist.json")
continue
p = os.path.join(tmp, name + ".json")
with open(p, "w", encoding="utf-8") as fh:
fh.write(content)
paths[name] = p
for suffix, _stdin, want_rc, bad in CLI_CASES:
argv = [sys.executable, ENGINE] + [
paths.get(a, a) if a in paths else a for a in suffix]
proc = subprocess.run(argv, capture_output=True, text=True,
timeout=60)
label = " ".join(suffix)
rep.check(proc.returncode == want_rc, f"cli rc {label}",
f"want {want_rc}, got {proc.returncode}")
rep.check(bad not in proc.stderr, f"cli clean stderr {label}",
" ".join(proc.stderr.split())[:100])
rep.check(proc.stderr.count("\n") <= 2 or want_rc == 0,
f"cli one-line error {label}",
" ".join(proc.stderr.split())[:100])
# ---------------------------------------------------------------------------
def main(argv=None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
quiet = "--quiet" in argv
rep = Report(quiet)
stage_pointer_grammar(rep)
rep.summary("pointer grammar")
stage_resolve(rep)
rep.summary("resolve oracle")
stage_error_contract(rep)
rep.summary("error contract")
stage_op_outcomes(rep)
rep.summary("op outcomes")
stage_cli(rep)
rep.summary("cli")
print(f"\nTOTAL: {rep.checks} checks, {len(rep.failures)} failed")
for f in rep.failures[:20]:
print(f" FAILED: {f}")
print("VERDICT: PASS" if not rep.failures else "VERDICT: FAIL")
return 0 if not rep.failures else 1
if __name__ == "__main__":
sys.exit(main())
+611
View File
@@ -0,0 +1,611 @@
#!/usr/bin/env python3
"""jsonptr_patch.py -- RFC 6901 JSON Pointer + RFC 6902 JSON Patch.
Pure stdlib (json, copy, sys). No third-party imports.
Public API
----------
JSON Pointer (RFC 6901 s.3/s.4):
decode_pointer(ref) -> tuple[str, ...] validate + unescape
unescape_token(tok) -> str '~1' -> '/', '~0' -> '~'
escape_token(tok) -> str inverse
pointer_to_string(toks) -> str inverse of decode_pointer
resolve(doc, tokens) -> value strict read, raises on miss
JSON Patch (RFC 6902 s.4):
json_equal(a, b) -> bool JSON value equality
apply_patch(doc, patch) -> new document input is NEVER mutated
Errors: JsonPointerError and JsonPatchError (both ValueError subclasses).
Semantics, measured against the vendored official suite
json-patch/json-patch-tests (tests.json + spec_tests.json, sha256 pinned in
test_jsonptr_patch.py):
* `add` never creates missing intermediate objects -- add /a/b against {} is
an error (spec_tests.json case "A.12. Adding to a Non-existent Target").
* array `add`: index == len(array) is legal, index > len(array) is an error,
"-" appends.
* `remove` / `replace` / `copy` / `test` require the target to exist;
`move` requires `from` to exist and forbids moving into a strict
descendant of `from`.
* `test` uses JSON value equality: 1 == 1.0, true != 1, null == null,
object member order is irrelevant, array element order is significant.
* the patch is atomic: every op is applied to a deep copy and the copy is
returned only if all ops succeed; the caller's document is untouched either
way.
CLI:
python3 jsonptr_patch.py --selftest [--verbose]
python3 jsonptr_patch.py --selftest-json
python3 jsonptr_patch.py --doc doc.json --patch patch.json
Exit codes: 0 = success, 1 = RFC 6901/6902 error, 2 = usage error.
"""
from __future__ import annotations
import copy
import json
import sys
__all__ = [
"JsonPointerError", "JsonPatchError",
"decode_pointer", "unescape_token", "escape_token", "pointer_to_string",
"resolve", "json_equal", "apply_patch", "run_selftest",
]
PATCH_ERR = 1
USAGE_ERR = 2
class JsonPointerError(ValueError):
"""Malformed JSON Pointer string (RFC 6901 s.3/s.4)."""
class JsonPatchError(ValueError):
"""Malformed patch, or a failing operation (RFC 6902 s.4)."""
# ---------------------------------------------------------------------------
# RFC 6901 -- JSON Pointer
# ---------------------------------------------------------------------------
def escape_token(tok: str) -> str:
"""'~' -> '~0' first, then '/' -> '~1' (order matters: '~/' -> '~0~1')."""
return str(tok).replace("~", "~0").replace("/", "~1")
def unescape_token(tok: str) -> str:
"""Inverse of escape_token. '~0' -> '~', '~1' -> '/'; anything else is illegal."""
out = []
i, n = 0, len(tok)
while i < n:
ch = tok[i]
if ch != "~":
out.append(ch)
i += 1
continue
if i + 1 >= n:
raise JsonPointerError(f"dangling '~' at end of reference token {tok!r}")
nxt = tok[i + 1]
if nxt == "0":
out.append("~")
elif nxt == "1":
out.append("/")
else:
raise JsonPointerError(
f"invalid escape '~{nxt}' in reference token {tok!r} "
"(only ~0 and ~1 are legal)"
)
i += 2
return "".join(out)
def decode_pointer(ref: str) -> tuple[str, ...]:
"""Split a JSON Pointer string into unescaped reference tokens.
Grammar (RFC 6901 s.3): json-pointer = *( "/" reference-token )
"" -> () the whole document
"/a/b" -> ("a", "b")
"a" -> rejected: must be empty or start with "/"
"/a~2b" -> rejected: '~2' is not a legal escape
"""
if not isinstance(ref, str):
raise JsonPointerError(f"pointer must be a str, got {type(ref).__name__}")
if ref == "":
return ()
if not ref.startswith("/"):
raise JsonPointerError(f"pointer {ref!r} must be empty or start with '/'")
return tuple(unescape_token(t) for t in ref[1:].split("/"))
def pointer_to_string(tokens) -> str:
"""Inverse of decode_pointer: () -> "", ("a", "b") -> "/a/b"."""
return "".join("/" + escape_token(t) for t in tokens)
def is_array_index(tok: str) -> bool:
"""RFC 6901 s.4: digits only, and a leading zero is only allowed for '0'.
``str.isdigit()`` is NOT sufficient: it is True for Unicode digit glyphs
such as ``"²"`` (SUPERSCRIPT TWO) and ``"١"`` (ARABIC-INDIC ONE), whose
``int()`` raises a plain ``ValueError`` -- which would leak past the
JsonPointerError contract. ``str.isascii()`` pins the check to the ASCII
digits that ``int()`` actually accepts.
"""
if not tok or not tok.isascii() or not tok.isdigit():
return False
return tok == "0" or tok[0] != "0"
def _descend(node, tok: str):
"""Walk one token. Arrays accept an RFC 6901 array-index token."""
if isinstance(node, dict):
if tok not in node:
raise JsonPointerError(f"key {tok!r} not found in object")
return node[tok]
if isinstance(node, list):
if not is_array_index(tok):
raise JsonPointerError(f"invalid array index {tok!r}")
idx = int(tok)
if idx >= len(node):
raise JsonPointerError(
f"array index {tok!r} out of range (len={len(node)})"
)
return node[idx]
raise JsonPointerError(
f"cannot index {type(node).__name__} with {tok!r}: not an object or array"
)
def resolve(doc, tokens) -> object:
"""Evaluate a token tuple against `doc`; raise JsonPointerError on any miss."""
node = doc
for tok in tokens:
node = _descend(node, tok)
return node
# ---------------------------------------------------------------------------
# JSON value equality (used by the `test` operation)
# ---------------------------------------------------------------------------
def json_equal(a, b) -> bool:
"""RFC 6902 `test` semantics.
Numbers compare numerically (1 == 1.0); booleans are a distinct type from
numbers (True != 1); null equals only null; object member order is
irrelevant; array order is significant.
"""
if isinstance(a, bool) or isinstance(b, bool):
return isinstance(a, bool) and isinstance(b, bool) and a is b
if a is None or b is None:
return a is None and b is None
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
return a == b
if isinstance(a, str) or isinstance(b, str):
return isinstance(a, str) and isinstance(b, str) and a == b
if isinstance(a, dict):
if not isinstance(b, dict) or set(a) != set(b):
return False
return all(json_equal(a[k], b[k]) for k in a)
if isinstance(b, dict):
return False
if isinstance(a, list):
if not isinstance(b, list) or len(a) != len(b):
return False
return all(json_equal(x, y) for x, y in zip(a, b))
return False
# ---------------------------------------------------------------------------
# RFC 6902 -- JSON Patch
# ---------------------------------------------------------------------------
_OPS = ("add", "remove", "replace", "move", "copy", "test")
def _canonical(value) -> str:
"""Canonical JSON, used only for error messages."""
return json.dumps(value, sort_keys=True, ensure_ascii=False,
separators=(",", ":"))
def _require(cond: bool, msg: str) -> None:
if not cond:
raise JsonPatchError(msg)
def _parent_and_token(doc, tokens):
"""Return (container, final_token) for a non-root pointer, validated."""
_require(bool(tokens), "operation target must not be the whole document")
container = resolve(doc, tokens[:-1])
tok = tokens[-1]
if isinstance(container, dict):
_require(tok in container,
f"path {pointer_to_string(tokens)!r} does not exist")
elif isinstance(container, list):
_require(tok != "-", "'-' is not a valid target for this operation")
_require(is_array_index(tok), f"invalid array index {tok!r}")
_require(int(tok) < len(container),
f"array index {tok!r} out of range (len={len(container)})")
else:
raise JsonPatchError(
f"path {pointer_to_string(tokens)!r} targets a scalar"
)
return container, tok
def _op_add(doc, tokens, value):
if not tokens: # root replacement
return copy.deepcopy(value)
container = resolve(doc, tokens[:-1]) # strict: parents must exist
tok = tokens[-1]
if isinstance(container, list):
if tok == "-":
container.append(copy.deepcopy(value))
return doc
_require(is_array_index(tok), f"invalid array index {tok!r}")
idx = int(tok)
_require(idx <= len(container),
f"array index {tok!r} is greater than number of items "
f"in array (len={len(container)})")
container.insert(idx, copy.deepcopy(value))
return doc
if isinstance(container, dict):
container[tok] = copy.deepcopy(value) # create-or-replace
return doc
raise JsonPatchError(
f"cannot add into {type(container).__name__} at "
f"{pointer_to_string(tokens)!r}"
)
def _op_remove(doc, tokens):
container, tok = _parent_and_token(doc, tokens)
if isinstance(container, dict):
del container[tok]
else:
del container[int(tok)]
return doc
def _op_replace(doc, tokens, value):
if not tokens:
return copy.deepcopy(value)
container, tok = _parent_and_token(doc, tokens)
if isinstance(container, dict):
container[tok] = copy.deepcopy(value)
else:
container[int(tok)] = copy.deepcopy(value)
return doc
def _op_move(doc, tokens, from_tokens):
_require(bool(from_tokens), "'move' 'from' must not be the whole document")
if pointer_to_string(from_tokens) == pointer_to_string(tokens):
return doc # /a -> /a is a no-op
# Moving an object into its own descendant would destroy the target path.
_require(
not (len(tokens) > len(from_tokens)
and tuple(tokens[:len(from_tokens)]) == from_tokens),
f"cannot move {pointer_to_string(from_tokens)!r} into its own "
f"descendant {pointer_to_string(tokens)!r}",
)
moved = copy.deepcopy(resolve(doc, from_tokens))
_op_remove(doc, from_tokens)
_op_add(doc, tokens, moved) # `to` resolves on the post-removal doc
return doc
def _op_copy(doc, tokens, from_tokens):
_require(bool(from_tokens), "'copy' 'from' must not be the whole document")
src = resolve(doc, from_tokens)
_op_add(doc, tokens, copy.deepcopy(src))
return doc
def _op_test(doc, tokens, value):
actual = resolve(doc, tokens)
_require(
json_equal(actual, value),
f"test failed at {pointer_to_string(tokens)!r}: "
f"expected {_canonical(value)!r}, got {_canonical(actual)!r}",
)
return doc
def _apply(work, op):
if not isinstance(op, dict):
raise JsonPatchError(
f"each patch entry must be an object, got {type(op).__name__}"
)
name = op.get("op")
_require(isinstance(name, str) and name in _OPS,
f"unrecognized op {name!r}")
_require("path" in op, f"missing 'path' parameter to {name!r}")
if name in ("add", "replace", "test"):
_require("value" in op, f"missing 'value' parameter to {name!r}")
if name in ("move", "copy"):
_require("from" in op, f"missing 'from' parameter to {name!r}")
try:
tokens = decode_pointer(op["path"])
if name in ("move", "copy"):
from_tokens = decode_pointer(op["from"])
else:
from_tokens = ()
except JsonPointerError as exc:
raise JsonPatchError(str(exc)) from exc
if name == "add":
return _op_add(work, tokens, op["value"])
if name == "remove":
return _op_remove(work, tokens)
if name == "replace":
return _op_replace(work, tokens, op["value"])
if name == "move":
return _op_move(work, tokens, from_tokens)
if name == "copy":
return _op_copy(work, tokens, from_tokens)
return _op_test(work, tokens, op["value"])
def apply_patch(doc, patch):
"""Apply an RFC 6902 patch and return the patched document.
`doc` is never mutated: the operations run on a deep copy which is returned
only when every op succeeds, so a failing patch is atomic (nothing applied).
"""
if not isinstance(patch, list):
raise JsonPatchError("a JSON Patch must be an array of operations")
work = copy.deepcopy(doc)
for i, op in enumerate(patch):
try:
work = _apply(work, op)
except (JsonPatchError, JsonPointerError) as exc:
raise JsonPatchError(
f"patch[{i}] ({op.get('op') if isinstance(op, dict) else op!r}): {exc}"
) from exc
return work
def apply_patch_text(doc_str: str, patch_str: str) -> str:
"""Parse JSON text, apply the patch, return the result as JSON text."""
result = apply_patch(json.loads(doc_str), json.loads(patch_str))
return json.dumps(result, ensure_ascii=False)
# ---------------------------------------------------------------------------
# Self-test
# ---------------------------------------------------------------------------
def _selftest_cases():
"""(name, doc, patch, expected_or_None, must_raise)."""
return [
# ---- RFC 6902 Appendix A, verbatim ----
("A.1 add object member", {"foo": "bar"},
[{"op": "add", "path": "/baz", "value": "qux"}],
{"foo": "bar", "baz": "qux"}, False),
("A.2 add array element", {"foo": ["bar", "baz"]},
[{"op": "add", "path": "/foo/1", "value": "qux"}],
{"foo": ["bar", "qux", "baz"]}, False),
("A.3 remove object member", {"baz": "qux", "foo": "bar"},
[{"op": "remove", "path": "/baz"}], {"foo": "bar"}, False),
("A.4 remove array element", {"foo": ["bar", "qux", "baz"]},
[{"op": "remove", "path": "/foo/1"}], {"foo": ["bar", "baz"]}, False),
("A.5 replace value", {"baz": "qux", "foo": "bar"},
[{"op": "replace", "path": "/baz", "value": "boo"}],
{"baz": "boo", "foo": "bar"}, False),
("A.6 replace missing -> error", {"foo": "bar"},
[{"op": "replace", "path": "/baz", "value": "qux"}], None, True),
("A.7 move value",
{"foo": {"bar": "baz", "waldo": "fred"}, "qux": {"corge": "grault"}},
[{"op": "move", "from": "/foo/waldo", "path": "/qux/thud"}],
{"foo": {"bar": "baz"}, "qux": {"corge": "grault", "thud": "fred"}}, False),
("A.8 move array element", {"foo": ["all", "grass", "cows", "eat"]},
[{"op": "move", "from": "/foo/1", "path": "/foo/3"}],
{"foo": ["all", "cows", "eat", "grass"]}, False),
("A.9 test success", {"baz": "qux", "foo": ["a", 2, "c"]},
[{"op": "test", "path": "/baz", "value": "qux"}],
{"baz": "qux", "foo": ["a", 2, "c"]}, False),
("A.10 test failure -> error", {"baz": "qux"},
[{"op": "test", "path": "/baz", "value": "bar"}], None, True),
("A.11 add to root", {"foo": "bar"},
[{"op": "add", "path": "", "value": {"baz": "qux"}}],
{"baz": "qux"}, False),
("A.12 escaped ~1", {"": "/", "/": " slash "},
[{"op": "test", "path": "/~1", "value": " slash "}],
{"": "/", "/": " slash "}, False),
("A.13 escaped ~0", {"~": "tilde", "/": "slash"},
[{"op": "test", "path": "/~0", "value": "tilde"}],
{"~": "tilde", "/": "slash"}, False),
("A.14 add out of range -> error", {"foo": ["bar", "qux", "baz"]},
[{"op": "add", "path": "/foo/5", "value": "qux"}], None, True),
("A.15 append with '-'", {"foo": ["bar", "baz"]},
[{"op": "add", "path": "/foo/-", "value": "qux"}],
{"foo": ["bar", "baz", "qux"]}, False),
("A.16 add into existing nested", {"foo": {"bar": "baz"}},
[{"op": "add", "path": "/foo/bar", "value": "qux"}],
{"foo": {"bar": "qux"}}, False),
("A.17 test array element", {"foo": ["bar", "baz"]},
[{"op": "test", "path": "/foo/1", "value": "baz"}],
{"foo": ["bar", "baz"]}, False),
("A.18 test object", {"foo": ["bar", "baz"]},
[{"op": "test", "path": "/foo", "value": ["bar", "baz"]}],
{"foo": ["bar", "baz"]}, False),
("A.19 test nested value", {"foo": {"bar": {"baz": "qux"}}},
[{"op": "test", "path": "/foo/bar/baz", "value": "qux"}],
{"foo": {"bar": {"baz": "qux"}}}, False),
("A.20 add into nested array", {"foo": [{"bar": "baz"}]},
[{"op": "add", "path": "/foo/0/bar", "value": "qux"}],
{"foo": [{"bar": "qux"}]}, False),
("A.21 test failure -> error", {"foo": ["bar", "baz"]},
[{"op": "test", "path": "/foo/1", "value": "qux"}], None, True),
# ---- spec_tests.json extras ----
("spec 4.1 add with missing object -> error", {"q": {"bar": 2}},
[{"op": "add", "path": "/a/b", "value": 1}], None, True),
("spec A.12 add to non-existent target -> error", {"foo": "bar"},
[{"op": "add", "path": "/baz/bat", "value": "qux"}], None, True),
("spec A.14 ~ escape ordering", {"/": 9, "~1": 10},
[{"op": "test", "path": "/~01", "value": 10}],
{"/": 9, "~1": 10}, False),
("spec A.15 number vs string -> error", {"/": 9, "~1": 10},
[{"op": "test", "path": "/~01", "value": "10"}], None, True),
("spec A.16 append array value", {"foo": ["bar"]},
[{"op": "add", "path": "/foo/-", "value": ["abc", "def"]}],
{"foo": ["bar", ["abc", "def"]]}, False),
# ---- RFC 6901 pointer quirks ----
("6901 whole document pointer is ''", {"a": 1},
[{"op": "test", "path": "", "value": {"a": 1}}], {"a": 1}, False),
("6901 leading-zero index rejected", {"foo": ["a", "b"]},
[{"op": "remove", "path": "/foo/01"}], None, True),
("6901 '-' is not a read index", {"foo": ["a"]},
[{"op": "remove", "path": "/foo/-"}], None, True),
("6901 '~2' escape rejected", {"foo": 1},
[{"op": "test", "path": "/fo~2o", "value": 1}], None, True),
("6901 dangling '~' rejected", {"foo": 1},
[{"op": "test", "path": "/foo~", "value": 1}], None, True),
("6901 pointer must start with '/'", {"foo": 1},
[{"op": "test", "path": "foo", "value": 1}], None, True),
("6901 path must be a string", {"foo": 1},
[{"op": "test", "path": None, "value": 1}], None, True),
# ---- operation semantics ----
("move /a -> /a is a no-op", {"a": 1, "b": 2},
[{"op": "move", "from": "/a", "path": "/a"}], {"a": 1, "b": 2}, False),
("move into own descendant -> error", {"a": {"b": 1}},
[{"op": "move", "from": "/a", "path": "/a/b"}], None, True),
("copy array element into object", {"foo": ["a", "b"]},
[{"op": "copy", "from": "/foo/0", "path": "/bar"}],
{"foo": ["a", "b"], "bar": "a"}, False),
("copy into array middle", {"foo": [1, 2, 3]},
[{"op": "copy", "from": "/foo/0", "path": "/foo/1"}],
{"foo": [1, 1, 2, 3]}, False),
("add at index == len is legal", {"foo": [1, 2]},
[{"op": "add", "path": "/foo/2", "value": 3}], {"foo": [1, 2, 3]}, False),
("replace whole document", {"a": 1},
[{"op": "replace", "path": "", "value": [1, 2]}], [1, 2], False),
("remove whole document -> error", {"a": 1},
[{"op": "remove", "path": ""}], None, True),
("empty patch is a no-op", {"a": 1}, [], {"a": 1}, False),
("chained ops on the live doc", {"a": 1},
[{"op": "add", "path": "/b", "value": 2},
{"op": "remove", "path": "/a"},
{"op": "replace", "path": "/b", "value": 3}], {"b": 3}, False),
("unknown op rejected", {"a": 1},
[{"op": "spam", "path": "/a", "value": 1}], None, True),
("missing value rejected", {"a": 1},
[{"op": "add", "path": "/b"}], None, True),
("missing path rejected", {"a": 1},
[{"op": "remove"}], None, True),
("patch must be an array", {"a": 1},
{"op": "add", "path": "/b", "value": 2}, None, True),
("non-object patch entry rejected", {"a": 1}, ["add"], None, True),
# ---- test-op equality ----
("test: key order irrelevant", {"b": 1, "a": 2},
[{"op": "test", "path": "", "value": {"a": 2, "b": 1}}],
{"b": 1, "a": 2}, False),
("test: 1 == 1.0", {"a": 1},
[{"op": "test", "path": "/a", "value": 1.0}], {"a": 1}, False),
("test: true != 1", {"a": True},
[{"op": "test", "path": "/a", "value": 1}], None, True),
("test: false != 0", {"a": False},
[{"op": "test", "path": "/a", "value": 0}], None, True),
("test: null == null", {"a": None},
[{"op": "test", "path": "/a", "value": None}], {"a": None}, False),
("test: array order significant", {"a": [1, 2]},
[{"op": "test", "path": "/a", "value": [2, 1]}], None, True),
("test: deep-copied value is not shared", {"a": {"x": 1}},
[{"op": "copy", "from": "/a", "path": "/b"}],
{"a": {"x": 1}, "b": {"x": 1}}, False),
]
def run_selftest(verbose: bool = False) -> int:
"""RFC 6902 Appendix A + RFC 6901 + equality semantics. 0 = all pass."""
passed = failed = 0
for name, doc, patch, expected, must_raise in _selftest_cases():
try:
got = apply_patch(doc, patch)
except JsonPatchError as exc:
if must_raise:
passed += 1
if verbose:
print(f" PASS {name} (raised: {exc})")
else:
failed += 1
print(f" FAIL {name}: unexpected JsonPatchError: {exc}")
continue
except Exception as exc: # noqa: BLE001 - surface, never mask
failed += 1
print(f" FAIL {name}: unexpected {type(exc).__name__}: {exc}")
continue
if must_raise:
failed += 1
print(f" FAIL {name}: expected JsonPatchError, got {got!r}")
elif json_equal(got, expected):
passed += 1
if verbose:
print(f" PASS {name}")
else:
failed += 1
print(f" FAIL {name}: expected {_canonical(expected)!r}, "
f"got {_canonical(got)!r}")
print(f"\n== {passed} passed, {failed} failed ==")
return 0 if failed == 0 else 1
def _selftest_json() -> int:
doc_s = '{"foo": ["bar", "baz"], "n": 1}'
patch_s = ('[{"op":"add","path":"/foo/1","value":"qux"},'
'{"op":"test","path":"/n","value":1}]')
out_s = apply_patch_text(doc_s, patch_s)
want = '{"foo": ["bar", "qux", "baz"], "n": 1}'
if out_s == want:
print(f"json-selftest PASS -> {out_s}")
return 0
print(f"json-selftest FAIL: {out_s!r} != {want!r}")
return 1
def main(argv=None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
if "--selftest" in argv:
return run_selftest(verbose=("-v" in argv or "--verbose" in argv))
if "--selftest-json" in argv:
return _selftest_json()
doc_s = patch_s = None
i = 0
while i < len(argv):
if argv[i] == "--doc" and i + 1 < len(argv):
doc_s = argv[i + 1]
i += 2
elif argv[i] == "--patch" and i + 1 < len(argv):
patch_s = argv[i + 1]
i += 2
else:
print(f"usage: {sys.argv[0]} [--selftest [--verbose]] "
"[--selftest-json] | --doc FILE --patch FILE", file=sys.stderr)
return USAGE_ERR
if doc_s is None or patch_s is None:
print("usage: --doc FILE --patch FILE (or --selftest)", file=sys.stderr)
return USAGE_ERR
def _read(spec: str) -> str:
return sys.stdin.read() if spec == "-" else open(spec, encoding="utf-8").read()
try:
print(apply_patch_text(_read(doc_s), _read(patch_s)))
except (JsonPatchError, json.JSONDecodeError, OSError) as exc:
print(f"error: {exc}", file=sys.stderr)
return PATCH_ERR
return 0
if __name__ == "__main__":
sys.exit(main())
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""probe_surface.py -- hunt for non-Json*Error escapes across every input surface.
For each generated (doc, patch) pair we call apply_patch and record whether the
exception is one of the documented contract errors or something else leaking.
Also exercises the CLI as a subprocess for a sample.
"""
import json
import os
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ENGINE = os.path.join(HERE, "jsonptr_patch.py")
sys.path.insert(0, HERE)
import jsonptr_patch as E # noqa: E402
# Every weird token we can think of for a pointer segment.
WEIRD = [
"\u00b2", "\u00b3", "\u0661", "\u0662", "\u0660", "\U0001d7d9",
"\u00bd", "\u2153", "\uff11", "\u00b9", "\u2070", "\u06f1",
"1e0", "-0", "+0", "01", "0x1", " 1", "1 ", "0b1", "1_0",
"~", "~2", "~01", "~10", "~00", "~11", "", "-", "0", "00",
"\u0000", "\n", "\t", "a/b", "a~b", "%20", "\u200b",
]
DOCS = [
{},
[],
[1, 2, 3],
{"a": 1, "b": {"c": 2}},
{"\u00b2": "key-is-superscript"},
{"a": [10, 20]},
{"a": {"b": [{"c": 1}]}},
]
OPS = ["add", "remove", "replace", "move", "copy", "test"]
CONTRACT = (E.JsonPatchError, E.JsonPointerError)
def leaks(doc, patch):
"""Return None if the contract held, else (type, message)."""
try:
E.apply_patch(doc, patch)
except CONTRACT:
return None
except Exception as exc: # noqa: BLE001 - that is exactly what we hunt
return (type(exc).__name__, str(exc))
return None
def main():
found = []
n = 0
for doc in DOCS:
for tok in WEIRD:
ref = "/" + tok
for op in OPS:
patch = [{"op": op, "path": ref}]
if op in ("add", "replace", "test"):
patch[0]["value"] = 1
if op in ("move", "copy"):
patch[0]["from"] = ref
n += 1
leak = leaks(doc, patch)
if leak:
found.append((doc, ref, op, leak))
# two-token paths
for t1 in WEIRD:
for t2 in WEIRD:
ref = "/" + t1 + "/" + t2
patch = [{"op": "add", "path": ref, "value": 1}]
n += 1
leak = leaks(doc, patch)
if leak:
found.append((doc, ref, "add", leak))
print(f"apply_patch calls exercised: {n}")
print(f"contract violations (non-Json*Error escapes): {len(found)}")
seen = set()
for doc, ref, op, (tname, msg) in found:
key = (tname, msg)
if key in seen:
continue
seen.add(key)
print(f" {tname}: {msg} e.g. doc={doc!r} op={op} path={ref!r}")
# --- CLI surface on a sample of the leaks ---
print()
print("CLI surface (subprocess) on one violating input:")
if found:
_, ref, op, _ = found[0]
tmp = os.path.join(HERE, "_probe_cli_tmp")
os.makedirs(tmp, exist_ok=True)
doc_p = os.path.join(tmp, "d.json")
pat_p = os.path.join(tmp, "p.json")
json.dump(DOCS[0], open(doc_p, "w"))
payload = [{"op": op, "path": ref}]
if op in ("add", "replace", "test"):
payload[0]["value"] = 1
if op in ("move", "copy"):
payload[0]["from"] = ref
json.dump(payload, open(pat_p, "w"), ensure_ascii=False)
proc = subprocess.run([sys.executable, ENGINE, "--doc", doc_p,
"--patch", pat_p], capture_output=True, text=True)
print(f" rc={proc.returncode} traceback={'Traceback' in proc.stderr}")
print(f" stderr head: {' '.join(proc.stderr.split())[:160]}")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Enumerate the DISTINCT contract violations (type + message) found by the sweep."""
import json
import os
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ENGINE = os.path.join(HERE, "jsonptr_patch.py")
sys.path.insert(0, HERE)
import jsonptr_patch as E # noqa: E402
WEIRD = ["\u00b2", "\u00b3", "\u00b9", "\u2070", "\u0661", "\u0662", "\u0660",
"\u06f1", "\uff11", "\U0001d7d9", "\U0001d7da", "\u00bd", "\u2153",
"1e0", "-0", "+0", "01", "0x1", " 1", "1 ", "0b1", "1_0",
"~", "~2", "~01", "~10", "~00", "~11", "", "-", "0", "00",
"\u0000", "\n", "\t", "a/b", "a~b", "%20", "\u200b"]
DOCS = [{}, [], [1, 2, 3], {"a": 1, "b": {"c": 2}},
{"\u00b2": "k"}, {"a": [10, 20]}, {"a": {"b": [{"c": 1}]}}]
OPS = ["add", "remove", "replace", "move", "copy", "test"]
CONTRACT = (E.JsonPatchError, E.JsonPointerError)
found = {}
n = 0
for doc in DOCS:
for tok in WEIRD:
ref = "/" + tok
for op in OPS:
patch = [{"op": op, "path": ref}]
if op in ("add", "replace", "test"):
patch[0]["value"] = 1
if op in ("move", "copy"):
patch[0]["from"] = ref
n += 1
try:
E.apply_patch(doc, patch)
except CONTRACT:
pass
except Exception as exc: # noqa: BLE001
found.setdefault((type(exc).__name__, str(exc)),
(doc, ref, op))
print("calls=%d distinct violations=%d" % (n, len(found)))
for (tname, msg), (doc, ref, op) in sorted(found.items()):
print(" %-12s %s" % (tname, msg))
print(" e.g. doc=%r op=%s path=%r" % (doc, op, ref))
# Which ops surface it?
print()
ops_seen = {}
for (tname, msg), (doc, ref, op) in found.items():
ops_seen.setdefault(tname, set()).add(op)
for tname, ops in ops_seen.items():
print("%s surfaces via ops: %s" % (tname, sorted(ops)))
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""probe_unicode_red.py -- RED probe for the Unicode-digit array-index bug.
Reproduces three distinct symptoms of the same root cause (is_array_index uses
str.isdigit(), which is True for Unicode digit glyphs whose int() raises a
plain ValueError instead of JsonPointerError):
1. the library leaks a plain ValueError past its error contract
2. the CLI prints a traceback instead of a one-line error
3. two operations disagree on whether the same pointer is a valid index
Run: python3 probe_unicode_red.py (expect exit 1 = the bug is present)
"""
import json
import os
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ENGINE = os.path.join(HERE, "jsonptr_patch.py")
sys.path.insert(0, HERE)
import jsonptr_patch as E # noqa: E402
# Non-ASCII glyphs for which str.isdigit() is True.
SUPERSCRIPT_TWO = "\u00b2"
DEVANAGARI_ONE = "\u0661"
MATHEMATICAL_ONE = "\U0001d7d9"
def symptom_library_leak():
"""resolve() must raise JsonPointerError; it raises a bare ValueError."""
try:
E.resolve([1, 2, 3], (SUPERSCRIPT_TWO,))
except E.JsonPointerError:
return False, "JsonPointerError (contract held)"
except ValueError as exc:
return True, f"plain ValueError leaked: {exc}"
return True, "no error at all"
def symptom_op_divergence():
"""is_array_index and the op handlers must agree on the same token."""
try:
E.apply_patch([1, 2, 3], [{"op": "remove", "path": "/" + SUPERSCRIPT_TWO}])
except E.JsonPatchError as exc:
return False, f"JsonPatchError (contract held): {exc}"
except ValueError as exc:
return True, f"plain ValueError leaked from op handler: {exc}"
return True, "op handler accepted an index is_array_index accepts"
def symptom_cli_traceback():
"""The CLI must print one line and exit 1, never a traceback."""
tmp = os.path.join(HERE, "_probe_cli_tmp")
os.makedirs(tmp, exist_ok=True)
doc = os.path.join(tmp, "doc.json")
patch = os.path.join(tmp, "patch.json")
with open(doc, "w", encoding="utf-8") as fh:
fh.write("[1, 2, 3]")
with open(patch, "w", encoding="utf-8") as fh:
json.dump([{"op": "remove", "path": "/" + SUPERSCRIPT_TWO}], fh,
ensure_ascii=False)
proc = subprocess.run([sys.executable, ENGINE, "--doc", doc, "--patch", patch],
capture_output=True, text=True)
err = proc.stderr
bad = "Traceback" in err or proc.returncode not in (0, 1)
detail = (f"rc={proc.returncode}, stderr={' '.join(err.split())[:80]!r}"
f", traceback={'Traceback' in err}")
return bad, detail
def main():
symptoms = [("library leak", symptom_library_leak()),
("op divergence", symptom_op_divergence()),
("cli traceback", symptom_cli_traceback())]
print("Unicode-digit array-index red probe")
for name, (buggy, detail) in symptoms:
print(f" {'BUG ' if buggy else 'ok '} {name:16} {detail}")
any_bug = any(b for _, (b, _) in symptoms)
print(f"tokens: is_array_index({SUPERSCRIPT_TWO!r})="
f"{E.is_array_index(SUPERSCRIPT_TWO)}, "
f"is_array_index({DEVANAGARI_ONE!r})="
f"{E.is_array_index(DEVANAGARI_ONE)}, "
f"is_array_index({MATHEMATICAL_ONE!r})="
f"{E.is_array_index(MATHEMATICAL_ONE)}")
print("RED" if any_bug else "GREEN")
return 1 if any_bug else 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,566 @@
#!/usr/bin/env python3
"""test_jsonptr_patch.py -- verification battery for jsonptr_patch.py.
Stages (all must pass; the process exit code is the verdict):
1. vendored-suite integrity -- sha256 of the upstream suite files matches the
pins recorded when they were fetched, so the oracle cannot be edited
silently.
2. official suite -- json-patch/json-patch-tests (tests.json +
spec_tests.json) run against the engine. `disabled` cases are skipped.
Expected results are compared with stdlib json.dumps(sort_keys=True), not
with the engine's own equality function.
3. engine selftest -- RFC 6902 Appendix A + RFC 6901 pointer quirks +
test-op equality semantics.
4. atomicity -- a patch whose later op fails must leave the
caller's document byte-identical (canonical JSON comparison).
5. fuzz -- seeded random documents and patches; checks
(a) only JsonPatchError/JsonPointerError ever escape, (b) the input is
untouched on failure, (c) results are deterministic, (d) constructed
add/copy/move round-trips are identities, (e) constructed bad patches
raise, (f) `test` accepts the live value and rejects a mutated one.
6. teeth -- N source mutants of the engine are generated
and the whole battery is re-run against each; every mutant must be
killed (nonzero exit) or the stage fails.
Usage:
python3 test_jsonptr_patch.py # full battery
python3 test_jsonptr_patch.py --no-teeth # skip stage 6
python3 test_jsonptr_patch.py --engine FILE # run against a mutant copy
python3 test_jsonptr_patch.py --quiet # suppress per-case output
"""
from __future__ import annotations
import copy
import hashlib
import importlib.util
import json
import os
import random
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
ENGINE = os.path.join(HERE, "jsonptr_patch.py")
# Vendored upstream suite (fetched from raw.githubusercontent.com,
# json-patch/json-patch-tests @ master, 2026-10-21). Pinned so a silent edit of
# the oracle is a test failure, not a pass.
VENDORED = {
"_vendor_json-patch-tests_tests.json": (
"de3dce3d0d5029fed83007e50b54607750dd3d1478d3c59ca35fdc18fb1a04ae", 95),
"_vendor_json-patch-tests_spec_tests.json": (
"a26b050292207033e5cccc5d6102b7bd6f8add7db0d0680e5d46a7ecf40a8c7b", 17),
}
MUTANTS = [
("add index==len allowed", 'idx <= len(container)', 'idx < len(container)'),
("missing parent tolerated", '_require(tok in container,',
'_require(True or tok in container,'),
("bool is a number", 'if isinstance(a, bool) or isinstance(b, bool):',
'if isinstance(a, (int, float)) and isinstance(b, (int, float)):'),
("unescape ~0 wrong", 'out.append("~")', 'out.append("0")'),
("move into descendant allowed",
'not (len(tokens) > len(from_tokens)', '(len(tokens) > len(from_tokens)'),
("pointer slash not enforced", 'if not ref.startswith("/"):', 'if False:'),
("leading zeros allowed", 'return tok == "0" or tok[0] != "0"', 'return True'),
("remove root tolerated", '_require(bool(tokens), "operation target must not be the whole document")',
'pass'),
# Regression guard for the Unicode-digit array-index bug (2026-10-21): a
# bare str.isdigit() accepts "²", and int("²") raises a plain ValueError
# that leaks past the JsonPointerError contract. This mutant re-introduces
# exactly that bug, so the suite fails if the isascii() guard is ever
# dropped again. `conformance.py` detects it via the error-contract stage.
("unicode digit index allowed", 'if not tok or not tok.isascii() or not tok.isdigit():',
'if not tok or not tok.isdigit():'),
]
def load_engine(path: str = ENGINE):
spec = importlib.util.spec_from_file_location("jsonptr_patch_under_test", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def canon(value) -> str:
"""Independent comparison key: stdlib canonical JSON."""
return json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
class Report:
def __init__(self, quiet: bool):
self.quiet = quiet
self.checks = 0
self.failures = []
def check(self, ok: bool, label: str, detail: str = "") -> bool:
self.checks += 1
if not ok:
self.failures.append(f"{label}{(' :: ' + detail) if detail else ''}")
if not self.quiet:
print(f" FAIL {label}{(' :: ' + detail) if detail else ''}")
elif not self.quiet:
print(f" pass {label}")
return ok
def summary(self, stage: str) -> None:
print(f"[{stage}] {self.checks} checks, {len(self.failures)} failed")
# ---------------------------------------------------------------------------
# Stage 1 + 2: vendored official suite
# ---------------------------------------------------------------------------
def stage_vendored(rep: Report) -> int:
for fname, (digest, n_cases) in VENDORED.items():
path = os.path.join(HERE, fname)
raw = open(path, "rb").read()
got = hashlib.sha256(raw).hexdigest()
if not rep.check(got == digest, f"sha256 {fname}", f"{got} != {digest}"):
return 1
cases = json.loads(raw.decode("utf-8"))
if not rep.check(len(cases) == n_cases, f"case count {fname}",
f"{len(cases)} != {n_cases}"):
return 1
return 0
def stage_official(eng, rep: Report) -> int:
passed = failed = skipped = 0
for fname in VENDORED:
for case in json.loads(open(os.path.join(HERE, fname), encoding="utf-8").read()):
label = case.get("comment") or f"{fname}#{passed + failed + skipped}"
if case.get("disabled"):
skipped += 1
continue
doc = case["doc"]
snapshot = canon(doc)
try:
got = eng.apply_patch(doc, case["patch"])
except (eng.JsonPatchError, eng.JsonPointerError) as exc:
if "error" in case:
passed += 1
if canon(doc) != snapshot:
failed += 1
rep.check(False, f"{label} atomicity", canon(doc))
continue
failed += 1
rep.check(False, f"{label} raised", str(exc)[:120])
continue
except Exception as exc: # noqa: BLE001
failed += 1
rep.check(False, f"{label} wrong exception type",
f"{type(exc).__name__}: {exc}"[:120])
continue
if "error" in case:
failed += 1
rep.check(False, f"{label} expected error", canon(got)[:120])
continue
if canon(got) == canon(case["expected"]):
passed += 1
else:
failed += 1
rep.check(False, f"{label} result",
f"got {canon(got)[:120]} want {canon(case['expected'])[:120]}")
print(f" official suite: {passed} passed, {failed} failed, {skipped} skipped (disabled)")
rep.checks += passed
rep.check(failed == 0, "official suite", f"{failed} failed cases")
return 0 if failed == 0 else 1
# ---------------------------------------------------------------------------
# Stage 4: atomicity
# ---------------------------------------------------------------------------
def stage_atomicity(eng, rep: Report) -> int:
doc = {"a": {"b": [1, 2, 3]}, "c": "keep"}
snapshot = canon(doc)
# op 2 succeeds, op 3 fails -> nothing may have been applied
patch = [{"op": "add", "path": "/d", "value": 9},
{"op": "remove", "path": "/a/b/9"}]
try:
eng.apply_patch(doc, patch)
rep.check(False, "atomicity: expected failure", "patch returned a document")
except eng.JsonPatchError:
rep.check(canon(doc) == snapshot, "atomicity: doc unchanged after failure",
canon(doc))
# test-op failure after a successful mutation
doc2 = {"x": 1}
patch2 = [{"op": "add", "path": "/y", "value": 2},
{"op": "test", "path": "/x", "value": 99}]
try:
eng.apply_patch(doc2, patch2)
rep.check(False, "atomicity: expected test failure")
except eng.JsonPatchError:
rep.check(canon(doc2) == canon({"x": 1}),
"atomicity: doc unchanged after test failure", canon(doc2))
# success path must not mutate the caller's document
doc3 = {"k": [1]}
out = eng.apply_patch(doc3, [{"op": "add", "path": "/k/-", "value": 2}])
rep.check(canon(doc3) == canon({"k": [1]}), "purity: input untouched on success",
canon(doc3))
rep.check(canon(out) == canon({"k": [1, 2]}), "purity: returned doc patched",
canon(out))
return 0
# ---------------------------------------------------------------------------
# Stage 5: fuzz
# ---------------------------------------------------------------------------
LEAF_POOL = [None, True, False, 0, 1, -7, 2.5, "", "x", "héllo", "0", "~", "/"]
def rand_value(rng, depth=0):
r = rng.random()
if depth < 3 and r < 0.25:
return [rand_value(rng, depth + 1) for _ in range(rng.randint(0, 3))]
if depth < 3 and r < 0.5:
return {rng.choice(["a", "b", "k", "n", "0", "~1"]): rand_value(rng, depth + 1)
for _ in range(rng.randint(0, 3))}
return rng.choice(LEAF_POOL)
def rand_doc(rng):
if rng.random() < 0.25:
return rand_value(rng)
return {rng.choice(["a", "b", "arr", "obj", "n"]): rand_value(rng)
for _ in range(rng.randint(1, 4))}
def all_paths(node, prefix=()):
yield prefix
if isinstance(node, dict):
for k, v in node.items():
yield from all_paths(v, prefix + (str(k),))
elif isinstance(node, list):
for i, v in enumerate(node):
yield from all_paths(v, prefix + (str(i),))
def ptr(tokens) -> str:
return "".join("/" + str(t).replace("~", "~0").replace("/", "~1") for t in tokens)
def node_at(node, tokens):
for t in tokens:
node = node[int(t)] if isinstance(node, list) else node[t]
return node
def stage_fuzz(eng, rep: Report) -> int:
rng = random.Random(20261021)
stats = {"patches": 0, "ok": 0, "raised": 0,
"wrong_exc": 0, "mutated_input": 0, "nondeterministic": 0,
"identity_fail": 0, "expected_fail_ok": 0, "expected_ok_fail": 0}
for _ in range(2000):
doc = rand_doc(rng)
paths = [p for p in all_paths(doc) if p]
ops = []
for _ in range(rng.randint(1, 4)):
op = rng.choice(["add", "add", "remove", "replace", "move", "copy", "test"])
p = rng.choice(paths) if paths else ()
if op == "add":
if not p or rng.random() < 0.5:
ops.append({"op": "add", "path": "", "value": rand_value(rng)})
else:
parent, tok = p[:-1], p[-1]
try:
container = node_at(doc, parent) if parent else doc
except (KeyError, IndexError, TypeError):
ops.append({"op": "add", "path": ptr(p), "value": rand_value(rng)})
continue
if isinstance(container, dict):
newtok = tok if rng.random() < 0.5 else "new" + str(rng.randint(0, 9))
ops.append({"op": "add", "path": ptr(parent + (newtok,)),
"value": rand_value(rng)})
elif isinstance(container, list):
idx = "-" if rng.random() < 0.3 else str(rng.randint(0, len(container)))
ops.append({"op": "add", "path": ptr(parent + (idx,)),
"value": rand_value(rng)})
else:
ops.append({"op": "add", "path": ptr(p), "value": rand_value(rng)})
elif op in ("remove", "replace", "test"):
ops.append({"op": op, "path": ptr(p)})
if op != "remove":
ops[-1]["value"] = rand_value(rng)
else:
frm = rng.choice(paths) if paths else ()
ops.append({"op": op, "from": ptr(frm), "path": ptr(p)})
stats["patches"] += 1
snapshot = canon(doc)
try:
out1 = eng.apply_patch(doc, ops)
out2 = eng.apply_patch(copy.deepcopy(doc), ops)
except (eng.JsonPatchError, eng.JsonPointerError):
stats["raised"] += 1
if canon(doc) != snapshot:
stats["mutated_input"] += 1
continue
except Exception as exc: # noqa: BLE001
stats["wrong_exc"] += 1
if not rep.quiet:
print(f" FAIL fuzz wrong exception {type(exc).__name__}: {exc} "
f"ops={ops}")
continue
stats["ok"] += 1
if canon(out1) != canon(out2):
stats["nondeterministic"] += 1
if canon(doc) != snapshot:
stats["mutated_input"] += 1
rep.check(stats["wrong_exc"] == 0, "fuzz: only JsonPatchError escapes",
str(stats["wrong_exc"]))
rep.check(stats["mutated_input"] == 0, "fuzz: input never mutated",
str(stats["mutated_input"]))
rep.check(stats["nondeterministic"] == 0, "fuzz: deterministic",
str(stats["nondeterministic"]))
print(f" fuzz: {stats['patches']} patches, {stats['ok']} ok, "
f"{stats['raised']} rejected")
# ---- constructed identities ----
# copy X -> fresh slot, remove the slot => original document
# add fresh slot, remove the slot => original document
def identity_ops(doc, p, rng):
root = doc
if isinstance(root, dict):
slot = "/__copy__" if rng.random() < 0.5 else "/__add__"
elif isinstance(root, list):
slot = "/-" # append
else:
return None
try:
out = eng.apply_patch(doc, [{"op": "copy", "from": ptr(p), "path": slot}])
except eng.JsonPatchError:
return None
if isinstance(root, list):
idx = len(out) - 1 # the appended element
else:
idx = slot
try:
out = eng.apply_patch(out, [{"op": "remove", "path": idx}])
except eng.JsonPatchError:
return None
return out
ident = skipped = 0
for _ in range(500):
doc = rand_doc(rng)
paths = [p for p in all_paths(doc) if p]
if not paths:
skipped += 1
continue
p = rng.choice(paths)
out = identity_ops(doc, p, rng)
if out is None:
skipped += 1
continue
ident += 1
if canon(out) != canon(doc):
stats["identity_fail"] += 1
rep.check(stats["identity_fail"] == 0, "fuzz: copy/add-then-remove is identity",
f"{stats['identity_fail']} failures over {ident} trials "
f"({skipped} skipped)")
print(f" fuzz: identity {ident} ok / {stats['identity_fail']} failed / "
f"{skipped} skipped")
# move out and back
def move_roundtrip(doc, p):
root = doc
if isinstance(root, dict):
tmp, back = "/__tmp__", ptr(p)
elif isinstance(root, list):
tmp = "/-"
back = ptr(p)
else:
return None
try:
out = eng.apply_patch(doc, [{"op": "move", "from": ptr(p), "path": tmp}])
except eng.JsonPatchError:
return None
if isinstance(root, list):
tmp = f"/{len(out) - 1}"
try:
out = eng.apply_patch(out, [{"op": "move", "from": tmp, "path": back}])
except eng.JsonPatchError:
return None
return out
mv_ok = mv_fail = mv_skip = 0
for _ in range(500):
doc = rand_doc(rng)
paths = [p for p in all_paths(doc) if p]
if not paths:
mv_skip += 1
continue
out = move_roundtrip(doc, rng.choice(paths))
if out is None:
mv_skip += 1
continue
mv_ok += 1
if canon(out) != canon(doc):
mv_fail += 1
rep.check(mv_fail == 0, "fuzz: move out and back is identity",
f"{mv_fail} failures over {mv_ok + mv_fail} trials "
f"({mv_skip} skipped)")
# ---- constructed failures must raise ----
bad = 0
for _ in range(500):
doc = rand_doc(rng)
paths = [p for p in all_paths(doc) if p]
p = rng.choice(paths) if paths else ("a",)
cases = [
[{"op": "remove", "path": ptr(p + ("__nope__",))}],
[{"op": "replace", "path": ptr(p + ("__nope__",)), "value": 1}],
[{"op": "add", "path": ptr(p + ("__nope__", "x")), "value": 1}],
[{"op": "test", "path": ptr(p + ("__nope__",)), "value": 1}],
[{"op": "move", "from": ptr(p + ("__nope__",)), "path": "/x"}],
[{"op": "copy", "from": ptr(p + ("__nope__",)), "path": "/x"}],
]
for patch in cases:
try:
eng.apply_patch(doc, patch)
stats["expected_fail_ok"] += 1
except eng.JsonPatchError:
bad += 1
rep.check(stats["expected_fail_ok"] == 0,
"fuzz: bad paths are rejected", str(stats["expected_fail_ok"]))
print(f" fuzz: {bad} bad patches rejected as expected")
# ---- test op: live value accepted, mutated value rejected ----
t_ok = t_rej = t_wrong = 0
SENTINEL = "__SENTINEL_9f3a__"
for _ in range(1000):
doc = rand_doc(rng)
if SENTINEL in canon(doc):
continue
paths = [p for p in all_paths(doc) if p]
if not paths:
continue
p = rng.choice(paths)
live = node_at(doc, p)
if rng.random() < 0.5:
try:
eng.apply_patch(doc, [{"op": "test", "path": ptr(p), "value": live}])
t_ok += 1
except eng.JsonPatchError:
t_wrong += 1
else:
try:
eng.apply_patch(doc, [{"op": "test", "path": ptr(p), "value": SENTINEL}])
t_wrong += 1
except eng.JsonPatchError:
t_rej += 1
rep.check(t_wrong == 0, "fuzz: test accepts live value, rejects sentinel",
f"{t_wrong} wrong outcomes")
print(f" fuzz: test op {t_ok} accepted / {t_rej} rejected / {t_wrong} wrong")
return 0
# ---------------------------------------------------------------------------
# Stage 6: teeth (mutants must be killed)
# ---------------------------------------------------------------------------
def stage_teeth(rep: Report) -> int:
"""Every source mutant must be killed by the battery OR the conformance stage.
The conformance stage is run once per mutant because some regressions (the
Unicode-digit array-index bug, for instance) are invisible to the official
suite and to the engine's own selftest, and are only caught by the
independent error-contract sweep.
"""
src = open(ENGINE, encoding="utf-8").read()
killed = survived = 0
conformance = os.path.join(HERE, "conformance.py")
with tempfile.TemporaryDirectory(prefix="jsonptr_teeth_") as tmp:
for i, (name, old, new) in enumerate(MUTANTS):
if src.count(old) != 1:
rep.check(False, f"mutant anchor {name}",
f"anchor occurs {src.count(old)} times")
survived += 1
continue
mutant = src.replace(old, new)
mpath = os.path.join(tmp, f"mutant_{i}.py")
open(mpath, "w", encoding="utf-8").write(mutant)
proc = subprocess.run(
[sys.executable, os.path.abspath(__file__), "--engine", mpath,
"--no-teeth", "--quiet"],
capture_output=True, text=True, timeout=120)
conf = None
if proc.returncode == 0 and os.path.exists(conformance):
conf = subprocess.run(
[sys.executable, conformance, "--engine", mpath, "--quiet"],
capture_output=True, text=True, timeout=900)
if proc.returncode != 0 or (conf is not None and conf.returncode != 0):
killed += 1
if not rep.quiet:
print(f" killed {name}")
else:
survived += 1
rep.check(False, f"mutant survived: {name}",
proc.stdout.strip()[-200:])
if not rep.quiet:
print(f" SURVIVED {name}")
print(f" teeth: {killed} killed, {survived} survived")
rep.check(survived == 0, "teeth: every mutant killed", f"{survived} survived")
return 0 if survived == 0 else 1
# ---------------------------------------------------------------------------
def main(argv=None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
quiet = "--quiet" in argv
teeth = "--no-teeth" not in argv
engine_path = ENGINE
if "--engine" in argv:
engine_path = os.path.abspath(argv[argv.index("--engine") + 1])
rep = Report(quiet)
eng = load_engine(engine_path)
print(f"battery: engine={engine_path}")
rc = 0
rc |= stage_vendored(rep)
rc |= stage_official(eng, rep)
rep.summary("vendored+official")
ok_selftest = eng.run_selftest(verbose=False) == 0
rep.check(ok_selftest, "engine selftest (Appendix A + 6901 + equality)")
rep.summary("selftest")
stage_atomicity(eng, rep)
rep.summary("atomicity")
stage_fuzz(eng, rep)
rep.summary("fuzz")
# Independent conformance stage (broad pointer space + CLI contract).
if os.path.exists(os.path.join(HERE, "conformance.py")):
proc = subprocess.run(
[sys.executable, os.path.join(HERE, "conformance.py"),
"--engine", engine_path, "--quiet"],
capture_output=True, text=True, timeout=900)
last = proc.stdout.strip().splitlines()[-2:] if proc.stdout.strip() else []
rep.check(proc.returncode == 0, "conformance stage",
" | ".join(last) if last else proc.stderr.strip()[:200])
rep.summary("conformance")
if teeth:
stage_teeth(rep)
rep.summary("teeth")
print(f"\nTOTAL: {rep.checks} checks, {len(rep.failures)} failed")
for f in rep.failures[:20]:
print(f" FAILED: {f}")
print("VERDICT: PASS" if (not rep.failures and rc == 0) else "VERDICT: FAIL")
return 0 if (not rep.failures and rc == 0) else 1
if __name__ == "__main__":
sys.exit(main())
+123
View File
@@ -0,0 +1,123 @@
# regex_nfa.py — Thompson-NFA regex engine, differentially tested against `re`
Wizard: **null** (lane: Codeforge — hard coding and agentic reasoning)
Artifact: `wizard-work/null/regexnfa/regex_nfa.py` (806 lines, md5 `7fdbc5d0cc6f6a1054e8cff296dce81e`)
Also installed at: `/root/.hermes/wizard-outputs/null/regex_nfa.py` (identical md5)
## What it is
A from-scratch regular-expression engine: recursive-descent parser -> AST ->
counted-repetition expansion -> **Thompson NFA** (epsilon transitions) ->
subset simulation. Stdlib only, no third-party imports, no backtracking.
Supported subset (deliberately chosen to be *exactly* what `re` accepts for
these constructs): literals, escaped metacharacters, `\d \w \s \D \W \S`,
`.`, `[...]` classes with ranges/negation/shorthand unions, `(...)` and
`(?:...)` grouping, `* + ? {m} {m,} {m,n}`, `|` alternation including
empty branches, empty pattern/group. Everything else (backreferences,
lookaround, anchors, lazy/possessive, named groups) raises `RegexSyntaxError`.
Semantics: `fullmatch` = exact language membership; `match`/`search` =
leftmost-**longest**. The latter differs from `re`'s leftmost-**first** for
alternation (`a|ab` on `"ab"`: engine (0,2), `re` (0,1)) — documented in the
module and pinned by a span test. Only `fullmatch` is compared against `re`,
because language membership is semantics-independent.
## Evidence — `--check` (unit tests + differential fuzz + mutation test)
```
$ python3 regexnfa/regex_nfa.py --check
pathological (a|aa)*b vs 40 a's: False in 0.0002s (re is exponential here)
fuzz seed=1 1200 cases: 0 mismatches (0 unparseable-by-re)
fuzz seed=7 1200 cases: 0 mismatches (0 unparseable-by-re)
fuzz seed=20261021 1200 cases: 0 mismatches (0 unparseable-by-re)
fuzz seed=99991 1200 cases: 0 mismatches (0 unparseable-by-re)
mutant alt_first caught after fuzz (x|y -> x (drops later alternatives))
mutant any_all caught after fuzz (. -> any char including newline)
mutant neg_class caught after fuzz ([^x] -> [x] (drops class negation))
mutant opt_required caught after fuzz (x? -> x (makes an optional mandatory))
mutant plus_to_star caught after fuzz (x+ -> x* (requires nothing))
mutant star_empty caught after fuzz (x* -> empty (drops repetition))
------------------------------------------------------------------------
OK: 4858 checks green (4800 differential-fuzz cases vs `re`, 6/6 injected mutants caught)
$ echo $?
0
```
(wall clock 10.4s)
The check has three independent parts:
1. **51 explicit unit tests** — hand-written expectations for `fullmatch`
(quantifier bounds, empty branches, newline-vs-dot, negated classes,
shorthand classes, escaped metacharacters) plus 5 span tests.
2. **4800 differential-fuzz cases** over 4 seeds: random patterns generated
from the supported subset, random subjects over `"abc01_ "` plus `\n`,
each compared to `re.fullmatch`. Any disagreement is a failure.
3. **Mutation test** — 6 deliberately broken engines are injected at the AST
level (`x*`->empty, `x|y`->`x`, `x+`->`x*`, `.`->matches `\n` too,
`[^x]`->`[x]`, `x?`->`x`) and the fuzzer must catch **every one**. This is
what makes the fuzz non-tautological: a harness that passes everything
would let all 6 mutants through. Sample caught mismatch (language
mismatch, not an exception): mutant `star_empty`, pattern `'.*'` on
`'0cb1c'` — engine `False`, `re` `True`.
### Independent confirmation, fresh seed, 20000 cases
```
$ python3 regexnfa/regex_nfa.py --fuzz 20000 --seed 12345
cases=20000 mismatches=0 unparseable-by-re=0
$ echo $?
0
```
### CLI
```
$ python3 regexnfa/regex_nfa.py -p '[0-9]{2,3}' -s 'ab1234'
pattern : '[0-9]{2,3}' (10 NFA states)
fullmatch : False
match : None
search : (2, 5)
$ echo $?
0
```
## Evidence — no backtracking blowup (`--bench`)
`re.fullmatch('(a|aa)*b', 'a'*n)` is exponential in n; the NFA engine is flat.
```
$ python3 regexnfa/regex_nfa.py --bench
pattern '(a|aa)*b' against 'a'*n (answer is False/None for every n)
n re.fullmatch Regex.fullmatch
22 0.0078s 0.00030s re=None engine=False
26 0.1698s 0.00038s re=None engine=False
30 2.5602s 0.01635s re=None engine=False
34 >10s (cut) 0.00033s re=None engine=False
60 >10s (cut) 0.00512s re=None engine=False
240 >10s (cut) 0.03434s re=None engine=False
$ echo $?
0
```
(re's time multiplies ~10x per +4 input chars and hits the 10s cut at n=60;
the engine never exceeds 13 ms, including n=240. Wall clock 33.6s
for the whole bench, most of it spent inside the timeouts.)
## Reproduce
```
python3 wizard-work/null/regexnfa/regex_nfa.py --check # exit 0 = all green
python3 wizard-work/null/regexnfa/regex_nfa.py --bench
python3 wizard-work/null/regexnfa/regex_nfa.py --demo
```
## Honest limits
* Only the subset listed above is implemented; anything else raises.
* `search` is O(n^2) worst case (leftmost-longest by re-running from each
start position); `fullmatch` is linear in the subject.
* `\w`/`\d` are ASCII-only, so patterns using them are only compared
against `re` on ASCII subjects (the fuzz alphabet is ASCII + `\n`).
* `\D \W \S` are not accepted inside a character class (they are outside).
* Not a drop-in `re` replacement: no captures, no `findall`, no groups.
+806
View File
@@ -0,0 +1,806 @@
#!/usr/bin/env python3
r"""
regex_nfa.py -- a Thompson-NFA regular-expression engine, stdlib only.
WHY
---
This is a from-scratch regex engine used as a *differential test target*:
every pattern/string pair it evaluates is compared against Python's own
`re` module for language membership (`fullmatch`). If the two ever disagree
on a pattern inside the supported subset, one of them is wrong.
SUPPORTED SUBSET (exactly what `re` accepts here too)
-----------------------------------------------------
literals, escaped metacharacters \. \\ \* \+ \? \( \) \[ \] \| \{ \}
shorthand classes \d \w \s \D \W \S
dot . (any char except newline)
classes [abc] [a-z] [^a-z] [a-c\d]
grouping ( ... ) (?: ... )
quantifiers * + ? {m} {m,} {m,n} (greedy only)
alternation | (empty branches allowed)
empty pattern / empty group "" ()
NOT SUPPORTED (raises RegexSyntaxError)
---------------------------------------
backreferences, lookaround, anchors, inline flags, lazy/possessive
quantifiers, named groups, \b \A \Z \n \t escapes.
SEMANTICS
---------
* fullmatch(p, s) -> bool exact language membership.
* match/search -> (start, end) LEFTMOST-LONGEST (POSIX-ish) match.
This differs from Python's leftmost-first backtracking: for `a|ab` on
"ab" this engine reports span (0, 2); `re` reports (0, 1). Only
`fullmatch` is compared against `re` in the fuzz harness, because
language membership is semantics-independent.
* Matching is O(len(pattern) * len(string)) with no backtracking, so
`(a|aa)*b` against "aaaa...a" is fast where `re` is exponential.
MEASURED on this host (Python 3.14.7, `--bench` output, answer is
False/None for every n):
n=22 re 0.0125s engine 0.00026s
n=26 re 0.1241s engine 0.00028s
n=30 re 1.4592s engine 0.00028s
n=34 re 9.7160s engine 0.01231s
n=60 re >10s (cut) engine 0.00042s
n=240 re >10s (cut) engine 0.00893s
(~10x per +4 chars for `re`; flat for the engine.)
Reproduce with: python3 regex_nfa.py --bench
CHECKS
------
python3 regex_nfa.py --check
runs (1) explicit unit tests, (2) a differential fuzz against `re` over
several seeds, and (3) a MUTATION test that injects 6 deliberate engine
bugs and requires the fuzzer to catch every one. Exit code 0 = all green.
"""
from __future__ import annotations
import argparse
import random
import re
import sys
import time
# --------------------------------------------------------------------------
# character sets
# --------------------------------------------------------------------------
WHITESPACE = frozenset(" \t\n\r\f\v")
DIGITS = frozenset("0123456789")
WORD = frozenset(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"
)
ESCAPABLE = ".^$*+?()[]{}|\\"
class RegexSyntaxError(ValueError):
"""Pattern outside the supported subset."""
def shorthand(e: str):
if e == "d":
return ("class", DIGITS, False)
if e == "D":
return ("class", DIGITS, True)
if e == "w":
return ("class", WORD, False)
if e == "W":
return ("class", WORD, True)
if e == "s":
return ("class", WHITESPACE, False)
if e == "S":
return ("class", WHITESPACE, True)
raise RegexSyntaxError("unsupported escape \\%s" % e)
# --------------------------------------------------------------------------
# parser: pattern text -> raw AST
# AST nodes:
# ('empty',) ('char', c) ('any',)
# ('class', frozenset, neg) ('concat', [nodes]) ('alt', [nodes])
# ('repeat', node, m, n) n is None for unbounded
# --------------------------------------------------------------------------
class Parser:
def __init__(self, pattern: str):
self.p = pattern
self.i = 0
def peek(self):
return self.p[self.i] if self.i < len(self.p) else None
def take(self):
ch = self.peek()
self.i += 1
return ch
def expect(self, ch):
if self.peek() != ch:
raise RegexSyntaxError(
"expected %r at %d in %r" % (ch, self.i, self.p)
)
self.i += 1
def parse(self):
node = self.parse_alt()
if self.i != len(self.p):
raise RegexSyntaxError("unexpected %r at %d" % (self.peek(), self.i))
return node
def parse_alt(self):
branches = [self.parse_concat()]
while self.peek() == "|":
self.i += 1
branches.append(self.parse_concat())
return branches[0] if len(branches) == 1 else ("alt", branches)
def parse_concat(self):
items = []
while True:
c = self.peek()
if c is None or c == "|" or c == ")":
break
items.append(self.parse_repeat())
if not items:
return ("empty",)
return items[0] if len(items) == 1 else ("concat", items)
def parse_repeat(self):
node = self.parse_atom()
seen = False
while True:
c = self.peek()
if c in ("*", "+", "?"):
if seen:
raise RegexSyntaxError("multiple repeat at %d" % self.i)
self.i += 1
seen = True
if c == "*":
node = ("repeat", node, 0, None)
elif c == "+":
node = ("repeat", node, 1, None)
else:
node = ("repeat", node, 0, 1)
elif c == "{":
if seen:
raise RegexSyntaxError("multiple repeat at %d" % self.i)
seen = True
m, n = self.parse_braces()
node = ("repeat", node, m, n)
else:
break
return node
def parse_int(self):
start = self.i
while self.peek() is not None and self.peek().isdigit():
self.i += 1
if self.i == start:
raise RegexSyntaxError("expected number at %d" % self.i)
return int(self.p[start:self.i])
def parse_braces(self):
self.expect("{")
m = self.parse_int()
if self.peek() == "}":
self.i += 1
return m, m
self.expect(",")
if self.peek() == "}":
self.i += 1
return m, None
n = self.parse_int()
self.expect("}")
if n < m:
raise RegexSyntaxError("repeat range %d,%d out of order" % (m, n))
return m, n
def parse_atom(self):
c = self.take()
if c is None:
raise RegexSyntaxError("unexpected end of pattern")
if c == "(":
if self.p.startswith("?:", self.i):
self.i += 2
node = self.parse_alt()
self.expect(")")
return node
if c == ")":
raise RegexSyntaxError("unmatched ')' at %d" % (self.i - 1))
if c == "[":
return self.parse_class()
if c == ".":
return ("any",)
if c == "\\":
e = self.take()
if e is None:
raise RegexSyntaxError("trailing backslash")
if e in "dDwWsS":
return shorthand(e)
if e in ESCAPABLE:
return ("char", e)
raise RegexSyntaxError("unsupported escape \\%s" % e)
if c in "*+?":
raise RegexSyntaxError("nothing to repeat at %d" % (self.i - 1))
return ("char", c)
def parse_class(self):
neg = False
if self.peek() == "^":
self.i += 1
neg = True
chars = set()
first = True
while True:
c = self.peek()
if c is None:
raise RegexSyntaxError("unterminated character set")
if c == "]":
if first:
# a leading ']' is a literal, as in `re`
self.i += 1
chars.add("]")
first = False
continue
self.i += 1
break
first = False
if c == "\\":
self.i += 1
e = self.take()
if e is None:
raise RegexSyntaxError("trailing backslash in class")
if e in "dws":
chars |= set(shorthand(e)[1])
continue
if e in "DWS":
raise RegexSyntaxError(
"\\%s inside a character class is not supported" % e
)
if e in ESCAPABLE:
start = e
else:
raise RegexSyntaxError("unsupported escape \\%s in class" % e)
else:
self.i += 1
start = c
if (
self.peek() == "-"
and self.i + 1 < len(self.p)
and self.p[self.i + 1] != "]"
):
self.i += 1 # consume '-'
end = self.take()
if end == "\\":
end = self.take()
if end not in ESCAPABLE:
raise RegexSyntaxError("bad range end")
if end is None or ord(end) < ord(start):
raise RegexSyntaxError("bad range %r-%r" % (start, end))
for o in range(ord(start), ord(end) + 1):
chars.add(chr(o))
else:
chars.add(start)
if not chars:
raise RegexSyntaxError("empty character class")
return ("class", frozenset(chars), neg)
# --------------------------------------------------------------------------
# normalization: expand counted repetition into concat/alt/star
# --------------------------------------------------------------------------
def normalize(node):
k = node[0]
if k in ("empty", "char", "any", "class"):
return node
if k == "concat":
subs = [normalize(s) for s in node[1]]
subs = [s for s in subs if s[0] != "empty"]
if not subs:
return ("empty",)
return subs[0] if len(subs) == 1 else ("concat", subs)
if k == "alt":
return ("alt", [normalize(s) for s in node[1]])
if k == "star":
return ("star", normalize(node[1]))
if k == "repeat":
_, sub, m, n = node
sub = normalize(sub)
if n is None:
if m == 0:
return ("star", sub)
return ("concat", [sub] * m + [("star", sub)])
if n == 0:
return ("empty",)
if m == n:
return sub if m == 1 else ("concat", [sub] * m)
return ("concat", [sub] * m + [("alt", [("empty",), sub])] * (n - m))
raise RegexSyntaxError("bad node %r" % (node,))
# --------------------------------------------------------------------------
# NFA construction (Thompson)
# --------------------------------------------------------------------------
class NFA:
def __init__(self):
self.eps = [] # state -> [state]
self.trans = [] # state -> [(predicate, state)]
def new_state(self):
self.eps.append([])
self.trans.append([])
return len(self.eps) - 1
def build(node, nfa):
k = node[0]
if k == "empty":
s, e = nfa.new_state(), nfa.new_state()
nfa.eps[s].append(e)
return s, e
if k in ("char", "any", "class"):
s, e = nfa.new_state(), nfa.new_state()
nfa.trans[s].append((node, e))
return s, e
if k == "concat":
s, e = build(node[1][0], nfa)
for sub in node[1][1:]:
s2, e2 = build(sub, nfa)
nfa.eps[e].append(s2)
e = e2
return s, e
if k == "alt":
s, e = nfa.new_state(), nfa.new_state()
for sub in node[1]:
s2, e2 = build(sub, nfa)
nfa.eps[s].append(s2)
nfa.eps[e2].append(e)
return s, e
if k == "star":
s, e = nfa.new_state(), nfa.new_state()
s2, e2 = build(node[1], nfa)
nfa.eps[s].append(e)
nfa.eps[s].append(s2)
nfa.eps[e2].append(s2)
nfa.eps[e2].append(e)
return s, e
raise RegexSyntaxError("bad node %r" % (node,))
def matches(pred, ch):
k = pred[0]
if k == "char":
return ch == pred[1]
if k == "any":
return ch != "\n"
return (ch not in pred[1]) if pred[2] else (ch in pred[1])
# --------------------------------------------------------------------------
# the engine
# --------------------------------------------------------------------------
class Regex:
def __init__(self, pattern: str, mutate=None):
self.pattern = pattern
raw = Parser(pattern).parse()
if mutate:
raw = apply_mutation(raw, mutate)
self.ast = normalize(raw)
self.nfa = NFA()
self.start, self.accept = build(self.ast, self.nfa)
self.n_states = len(self.nfa.eps)
def __repr__(self):
return "Regex(%r, %d states)" % (self.pattern, self.n_states)
def _closure(self, states):
seen = set(states)
stack = list(states)
eps = self.nfa.eps
while stack:
s = stack.pop()
for t in eps[s]:
if t not in seen:
seen.add(t)
stack.append(t)
return seen
def _step(self, states, ch):
nxt = set()
for st in states:
for pred, tgt in self.nfa.trans[st]:
if matches(pred, ch):
nxt.add(tgt)
return self._closure(nxt) if nxt else set()
def fullmatch(self, s: str) -> bool:
cur = self._closure({self.start})
for ch in s:
if not cur:
return False
cur = self._step(cur, ch)
return self.accept in cur
def _longest_from(self, s, i):
cur = self._closure({self.start})
best = i if self.accept in cur else None
j = i
n = len(s)
while j < n and cur:
cur = self._step(cur, s[j])
j += 1
if self.accept in cur:
best = j
return best
def match(self, s):
e = self._longest_from(s, 0)
return (0, e) if e is not None else None
def search(self, s):
for i in range(len(s) + 1):
e = self._longest_from(s, i)
if e is not None:
return (i, e)
return None
# --------------------------------------------------------------------------
# mutation harness: deliberately broken engines, all of which MUST be caught
# by the differential fuzzer (this is what makes --check non-tautological)
# --------------------------------------------------------------------------
MUTATIONS = {
"star_empty": "x* -> empty (drops repetition)",
"alt_first": "x|y -> x (drops later alternatives)",
"plus_to_star": "x+ -> x* (requires nothing)",
"any_all": ". -> any char including newline",
"neg_class": "[^x] -> [x] (drops class negation)",
"opt_required": "x? -> x (makes an optional mandatory)",
}
def apply_mutation(node, mode):
k = node[0]
if k == "repeat":
sub = apply_mutation(node[1], mode)
m, n = node[2], node[3]
if mode == "star_empty" and m == 0 and n is None:
return ("empty",)
if mode == "plus_to_star" and m == 1 and n is None:
return ("repeat", sub, 0, None)
if mode == "opt_required" and m == 0 and n == 1:
return sub
return ("repeat", sub, m, n)
if k == "alt":
subs = [apply_mutation(s, mode) for s in node[1]]
return subs[0] if mode == "alt_first" else ("alt", subs)
if k == "concat":
return ("concat", [apply_mutation(s, mode) for s in node[1]])
if k == "any":
return ("class", frozenset(), True) if mode == "any_all" else node
if k == "class":
if mode == "neg_class" and node[2]:
return ("class", node[1], False)
return node
return node
# --------------------------------------------------------------------------
# differential fuzzer: random patterns from the shared subset, compared to `re`
# --------------------------------------------------------------------------
ALPHA = "abc01_ "
SPECIALS = ".^$*+?()[]{}|\\"
def gen_atom(rng, depth=0):
r = rng.random()
if r < 0.30:
return rng.choice(ALPHA)
if r < 0.44:
return "\\" + rng.choice(SPECIALS)
if r < 0.54:
return "."
if r < 0.69:
return rng.choice(["\\d", "\\w", "\\s", "\\D", "\\W", "\\S"])
if r < 0.89:
return gen_class(rng)
return "(" + gen_pattern(rng, depth + 1) + ")"
def gen_class(rng):
neg = "^" if rng.random() < 0.35 else ""
items = []
for _ in range(rng.randint(1, 3)):
r = rng.random()
if r < 0.50:
items.append(rng.choice(ALPHA))
elif r < 0.80:
a, b = sorted(rng.sample("abc012", 2))
items.append("%s-%s" % (a, b))
else:
items.append(rng.choice(["\\d", "\\w", "\\s"]))
return "[" + neg + "".join(items) + "]"
def gen_pattern(rng, depth=0):
r = rng.random()
if depth >= 3:
return gen_atom(rng, depth)
if r < 0.30:
return "".join(gen_atom(rng, depth + 1) for _ in range(rng.randint(2, 3)))
if r < 0.50:
n = rng.randint(2, 3)
branches = [gen_pattern(rng, depth + 1) for _ in range(n)]
if rng.random() < 0.20:
branches[rng.randrange(n)] = ""
return "(" + "|".join(branches) + ")"
if r < 0.80:
return gen_atom(rng, depth + 1) + rng.choice(
["*", "+", "?", "{2}", "{1,2}", "{0,3}", "{2,}"]
)
return gen_atom(rng, depth + 1)
def gen_string(rng, maxlen=8):
out = []
for _ in range(rng.randint(0, maxlen)):
out.append("\n" if rng.random() < 0.12 else rng.choice(ALPHA))
return "".join(out)
def fuzz(n, seed, mutate=None):
"""Return (mismatches, skips). mismatches = engine disagrees with `re`."""
rng = random.Random(seed)
mismatches = []
skips = 0
for _ in range(n):
p = gen_pattern(rng)
s = gen_string(rng)
try:
theirs = re.fullmatch(p, s) is not None
except re.error:
skips += 1
continue
try:
mine = Regex(p, mutate=mutate).fullmatch(s)
except RegexSyntaxError as exc:
mismatches.append((p, s, "engine-error: %s" % exc, theirs))
continue
if mine != theirs:
mismatches.append((p, s, mine, theirs))
if len(mismatches) >= 5:
break
return mismatches, skips
# --------------------------------------------------------------------------
# checks
# --------------------------------------------------------------------------
UNIT_TESTS = [
# pattern, string, fullmatch expected
("a", "a", True),
("a", "b", False),
("a", "", False),
("", "", True),
("a*", "", True),
("a*", "aaaa", True),
("a*", "aab", False),
("(a|b)*", "abba", True),
("(a|b)*", "abbc", False),
("(ab)+", "abab", True),
("(ab)+", "aba", False),
("a{2,3}", "aa", True),
("a{2,3}", "aaa", True),
("a{2,3}", "a", False),
("a{2,3}", "aaaa", False),
("a{2}", "aa", True),
("a{2,}", "aaaa", True),
("a{2,}", "a", False),
("a|", "", True),
("a|", "a", True),
("(a|)b", "b", True),
("[^a]", "b", True),
("[^a]", "a", False),
("[^a]", "\n", True), # negated classes do match newline in `re` too
(".", "x", True),
(".", "\n", False), # dot does not match newline
("\\d+", "123", True),
("\\d+", "12a", False),
("\\w+", "ab_1", True),
("\\w+", "a b", False),
("\\s", " ", True),
("\\s", "\n", True),
("\\S", "x", True),
("\\S", " ", False),
("\\D", "a", True),
("\\D", "7", False),
("[a-c0-2]+", "ab0c2", True),
("[a-c0-2]+", "abz", False),
("[a-c\\d]+", "ab7", True),
("[a-c\\d]+", "abz", False),
("(?:ab)+", "abab", True),
("\\.", ".", True),
("\\.", "x", False),
("\\*", "*", True),
("a\\.b", "a.b", True),
("[\\]]", "]", True),
]
SPAN_TESTS = [
# pattern, string, expected (start, end) under LEFTMOST-LONGEST
("a+", "baaa", (1, 4)),
("a*", "b", (0, 0)),
("a+", "bbb", None),
("[0-9]+", "ab123cd", (2, 5)),
# documented divergence from Python's leftmost-FIRST `re` (which gives (0,1)):
("a|ab", "ab", (0, 2)),
]
def run_check(verbose=True):
failures = []
total = 0
# (1) unit tests
for p, s, want in UNIT_TESTS:
total += 1
got = Regex(p).fullmatch(s)
if got != want:
failures.append("fullmatch(%r, %r) = %r, want %r" % (p, s, got, want))
if verbose and got != want:
print(" FAIL unit: fullmatch(%r, %r) = %r, want %r" % (p, s, got, want))
for p, s, want in SPAN_TESTS:
total += 1
got = Regex(p).search(s)
if got != want:
failures.append("search(%r, %r) = %r, want %r" % (p, s, got, want))
if verbose:
print(" FAIL span: search(%r, %r) = %r, want %r" % (p, s, got, want))
# (2) no-backtracking / pathological-case check
total += 1
t0 = time.perf_counter()
got = Regex("(a|aa)*b").fullmatch("a" * 40)
dt = time.perf_counter() - t0
if got is not False or dt > 2.0:
failures.append("pathological (a|aa)*b on a*40 -> %r in %.3fs" % (got, dt))
else:
print(" pathological (a|aa)*b vs 40 a's: %r in %.4fs (re is exponential here)"
% (got, dt))
# (3) differential fuzz against `re`, several seeds
fuzz_total = 0
for seed in (1, 7, 20261021, 99991):
mm, skips = fuzz(1200, seed)
fuzz_total += 1200
total += 1200
if mm:
failures.append("fuzz seed=%d mismatches: %r" % (seed, mm[:3]))
if verbose:
print(" FAIL fuzz seed=%d: %r" % (seed, mm[:3]))
elif verbose:
print(" fuzz seed=%-8d 1200 cases: 0 mismatches (%d unparseable-by-re)"
% (seed, skips))
# (4) mutation test: every injected bug must be caught by the fuzzer
caught = {}
for mode in sorted(MUTATIONS):
mm, _ = fuzz(600, 4242, mutate=mode)
caught[mode] = bool(mm)
total += 1
if not mm:
failures.append("mutant %r survived the fuzzer" % mode)
if verbose:
print(" FAIL mutant survived: %s (%s)" % (mode, MUTATIONS[mode]))
elif verbose:
print(" mutant %-14s caught after fuzz (%s)"
% (mode, MUTATIONS[mode]))
print("-" * 72)
if failures:
print("FAILED: %d problem(s) out of %d checks" % (len(failures), total))
for f in failures[:10]:
print(" - %s" % f)
return 1
print("OK: %d checks green (%d differential-fuzz cases vs `re`, "
"%d/%d injected mutants caught)"
% (total, fuzz_total, sum(caught.values()), len(caught)))
return 0
def _timed(fn, budget=10.0):
"""Run fn() under a SIGALRM budget. Returns (seconds, result) or (None, None)."""
import signal
def handler(signum, frame):
raise TimeoutError()
old = signal.signal(signal.SIGALRM, handler)
signal.setitimer(signal.ITIMER_REAL, budget)
t0 = time.perf_counter()
try:
res = fn()
return time.perf_counter() - t0, res
except TimeoutError:
return None, None
finally:
signal.setitimer(signal.ITIMER_REAL, 0)
signal.signal(signal.SIGALRM, old)
def bench(budget=10.0):
"""Show that `re` backtracks exponentially where this engine does not."""
print("pattern '(a|aa)*b' against 'a'*n (answer is False/None for every n)")
print(" %-6s %-14s %-14s" % ("n", "re.fullmatch", "Regex.fullmatch"))
for n in (22, 26, 30, 34, 60, 240):
s = "a" * n
t_re, r_re = _timed(lambda: re.fullmatch("(a|aa)*b", s), budget)
t_nfa, r_nfa = _timed(lambda: Regex("(a|aa)*b").fullmatch(s), budget)
print(" %-6d %-14s %-14s re=%r engine=%r"
% (n,
">%.0fs (cut)" % budget if t_re is None else "%.4fs" % t_re,
">%.0fs (cut)" % budget if t_nfa is None else "%.5fs" % t_nfa,
r_re, r_nfa))
return 0
def demo():
for p in ["a(b|c)*d", "(a|aa)*b", "[^0-9]{2,3}", "\\w+@\\w+", "(a|)b"]:
r = Regex(p)
print("%-14r %3d NFA states" % (p, r.n_states))
for s in ["abcd", "abbcd", "aaa", "x7", "b", ""]:
print(" fullmatch(%-8r) = %-5s search -> %r"
% (s, r.fullmatch(s), r.search(s)))
def main(argv=None):
ap = argparse.ArgumentParser(description="Thompson-NFA regex engine")
ap.add_argument("--check", action="store_true",
help="run unit tests + differential fuzz + mutation test")
ap.add_argument("--demo", action="store_true", help="print a small demo")
ap.add_argument("--bench", action="store_true",
help="measure re's backtracking blowup vs this engine")
ap.add_argument("-p", "--pattern", help="pattern to match")
ap.add_argument("-s", "--string", default="", help="subject string")
ap.add_argument("--fuzz", type=int, help="ad-hoc fuzz case count")
ap.add_argument("--seed", type=int, default=20261021, help="fuzz seed")
args = ap.parse_args(argv)
if args.check:
return run_check()
if args.demo:
demo()
return 0
if args.bench:
return bench()
if args.fuzz is not None:
mm, skips = fuzz(args.fuzz, args.seed)
print("cases=%d mismatches=%d unparseable-by-re=%d" % (args.fuzz, len(mm), skips))
for p, s, mine, theirs in mm[:10]:
print(" %r on %r: engine=%r re=%r" % (p, s, mine, theirs))
return 1 if mm else 0
if args.pattern is not None:
r = Regex(args.pattern)
print("pattern : %r (%d NFA states)" % (args.pattern, r.n_states))
print("fullmatch : %s" % r.fullmatch(args.string))
print("match : %r" % (r.match(args.string),))
print("search : %r" % (r.search(args.string),))
return 0
ap.print_help()
return 2
if __name__ == "__main__":
sys.exit(main())
+97
View File
@@ -0,0 +1,97 @@
# schedkit — a deterministic parallel scheduler for DAGs of agent tasks
Lane: Codeforge (null).
A troupe schedules work as a graph: tasks with prerequisites. Given the same graph,
this module must always produce the **same** execution plan — no threads, no clock, no
set-iteration order leaking into the output. The plan is a tuple of *waves*: the tasks
that may start together, ids sorted lexicographically inside each wave.
```
from schedkit import Graph, plan, find_cycles, plan_json, iter_plan
g = Graph().add_edges([("fetch", "parse"), ("fetch", "index"),
("parse", "publish"), ("index", "publish")])
plan(g) # (('fetch',), ('index', 'parse'), ('publish',))
find_cycles(g) # () -- a *result*, not a crash
```
## The two contracts
1. **Determinism.** `plan(g) == plan(g)` on every call, and
`plan(relabel(g))` equals `plan(g)` with every id substituted. Wave membership is
decided by the graph; order inside a wave by the ids alone.
2. **Cycles are data.** `find_cycles()` returns every distinct simple cycle,
canonically rotated, deduplicated, sorted. `plan()` raises `CycleError` carrying
them instead of returning a partial plan.
## Public API
| name | what it does |
|---|---|
| `Graph` | container; `add_task`, `add_dep`, `add_edge`, `copy`, `relabel`, `topological_order` |
| `find_cycles(g)` | every simple cycle — Tarjan SCC pass fronting Johnson's circuit search |
| `plan(g)` | tuple of waves; raises `CycleError` |
| `plan_graphviz(g)` | Graphviz DOT, waves as ranks |
| `plan_json(g)` | JSON plan: waves, per-task deps, validation block |
| `iter_plan(waves)` | flatten a plan in execution order |
stdlib only. No I/O, no clock, no randomness.
## Cost (measured, CPython 3.14)
| shape | cost |
|---|---|
| acyclic graph | `O(V+E)` — one SCC pass, no circuit search at all |
| one SCC of k | `O(k^2 + k·e_k + circuits)` — Johnson's own bound |
The pre-fix build (`schedkit_v1_quadratic.py`, kept in this directory as the negative
control) re-scanned every adjacency list per vertex, so an acyclic graph cost
`O(V·E)`; it took **7.88 s** to prove a 2,000-node chain had no cycles. The fixed build
does it in **0.0112 s** — the same measurement, 703× apart.
## Files
| file | role |
|---|---|
| `schedkit.py` | the module |
| `spec_schedkit.py` | executable spec, sections A–I; exit 0 = every contract held |
| `schedkit_v1_quadratic.py` | the pre-fix build, kept so the perf claim stays falsifiable |
| `mutate_teeth.py` | 12 single-substitution mutants + control + baseline; does the spec bite? |
| `check_schedkit.py` | one command, one verdict: green spec, 4 sampled mutants killed, cost differential reproduced |
| `evidence/` | captured output of each command above |
## Commands
```
python3 spec_schedkit.py # the spec alone
python3 mutate_teeth.py # control + baseline + 12 mutants
python3 check_schedkit.py # the one-line verdict (~40 s)
python3 check_schedkit.py --mutant-budget 20
```
## What is measured, not asserted
| claim | how it is measured |
|---|---|
| the committed module satisfies the spec | 124,840 checks, 0 failed (10.97 s) |
| the spec is not a decoration | 12/12 mutants killed; the pre-fix build fails it |
| `find_cycles` is right | differential against brute-force DFS cycle enumeration, 1,000 random DAGs |
| `topological_order` is the *smallest* linear extension | differential against brute force over **all permutations**, 300 random DAGs |
| the plan is isomorphism-invariant | 1,000 DAGs × 40 random relabellings, each compared to the substituted original |
| the perf fix is real | both builds timed on the same 2,000-node chain, same machine, in one run |
| deep graphs do not recurse | 50,000-node chain planned; 50,000-node ring has its one cycle found, `recursionlimit` 1000 |
## The blind spot this version closes
Mutant 7 in `mutate_teeth.py` swaps `heapq.heappop(ready)` for `ready.pop()` in
`Graph.topological_order`. That mutant **survived** the first build of this spec: the
resulting order is still a *valid* linear extension, and `plan()` sorts inside each
wave, so every wave-level check stayed green. The module's docstring promised
"min-id first" and nothing tested it.
Closed by section I: the contract is now stated precisely (the *lexicographically
smallest* topological order) and graded against brute force over all permutations
rather than against the module's own heap. Mutant 7 now fails the spec on check I1
(`('a','c','b','d')` where `('a','b','c','d')` is required) — captured in
`evidence/check_schedkit.txt`.
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""
check_schedkit.py -- one command, one verdict for the schedkit artifact.
Exit 0 == the committed schedkit.py is the fixed build, the spec that grades it
is green, and that spec bites.
Exit 1 == otherwise (the failing leg is printed).
Legs
1 spec on the committed module must exit 0
2 spec on four sampled mutants each must exit non-zero
3 cost differential, one 2,000-node chain, both builds, same machine:
the fixed build must be fast and the pre-fix build slow -- the perf claim
graded by measurement, not by assertion
Usage: python3 check_schedkit.py [--mutant-budget SECONDS]
"""
import os
import shutil
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import mutate_teeth as teeth # noqa: E402
# Four single-substitution bugs the spec must kill. Indices into teeth.MUTANTS;
# all four are pure-behaviour mutants, so each run costs one full spec pass.
SAMPLE = [0, 1, 7, 9]
TIMING_PROBE = '''
import importlib.util, sys, time
path, n = sys.argv[1], int(sys.argv[2])
spec = importlib.util.spec_from_file_location("probe_mod", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
g = mod.Graph()
for i in range(n - 1):
g.add_edge("d%06d" % i, "d%06d" % (i + 1))
t0 = time.monotonic()
cyc = mod.find_cycles(g)
print("%.4f %d" % (time.monotonic() - t0, len(cyc)))
'''
def main() -> int:
budget = 300.0
if "--mutant-budget" in sys.argv:
budget = float(sys.argv[sys.argv.index("--mutant-budget") + 1])
fails = []
print("check_schedkit -- %s" % HERE)
# -------------------------------------------------- leg 1: the spec is green
tmp = tempfile.mkdtemp(prefix="check-control-")
try:
shutil.copy(teeth.MODULE, os.path.join(tmp, "schedkit.py"))
shutil.copy(teeth.SPEC, os.path.join(tmp, "spec_schedkit.py"))
rc, out, dt = teeth.run_spec(tmp, budget)
print("1 spec on committed module rc=%s %.1fs %s" % (rc, dt, teeth.summary(out)))
if rc != 0:
fails.append("leg 1: the spec did not pass on the committed module")
finally:
shutil.rmtree(tmp, ignore_errors=True)
# ------------------------------------------------- leg 2: the spec has teeth
src = open(teeth.MODULE).read()
for idx in SAMPLE:
label, old, new = teeth.MUTANTS[idx]
if src.count(old) != 1:
fails.append("leg 2: mutant %d anchor is not unique" % idx)
print("2 mutant %-2d ANCHOR MISSING %s" % (idx, label))
continue
tmp = tempfile.mkdtemp(prefix="check-m%02d-" % idx)
try:
with open(os.path.join(tmp, "schedkit.py"), "w") as fh:
fh.write(src.replace(old, new))
shutil.copy(teeth.SPEC, os.path.join(tmp, "spec_schedkit.py"))
rc, out, dt = teeth.run_spec(tmp, budget)
ok = rc is None or rc != 0
print(
"2 mutant %-2d %-8s %.1fs %s"
% (idx, "CAUGHT" if ok else "SURVIVED", dt, label)
)
if not ok:
fails.append("leg 2: mutant %d survived: %s" % (idx, label))
finally:
shutil.rmtree(tmp, ignore_errors=True)
# ------------------------------------- leg 3: measured cost of both builds
tmp = tempfile.mkdtemp(prefix="check-timing-")
try:
probe = os.path.join(tmp, "probe.py")
with open(probe, "w") as fh:
fh.write(TIMING_PROBE)
n = 2000
times = {}
for name, mod in (("fixed", teeth.MODULE), ("pre-fix", teeth.BASELINE)):
if not os.path.exists(mod):
fails.append("leg 3: missing module %s" % mod)
continue
p = subprocess.run(
[sys.executable, probe, mod, str(n)],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=300,
text=True,
)
if p.returncode != 0:
fails.append("leg 3: probe on %s failed: %s" % (name, p.stdout.strip()))
continue
secs, cycles = p.stdout.split()
times[name] = float(secs)
print("3 %-7s chain n=%d find_cycles %.4fs, %s cycles" % (name, n, float(secs), cycles))
if cycles != "0":
fails.append("leg 3: %s reported %s cycles on an acyclic chain" % (name, cycles))
if "fixed" in times and times["fixed"] >= 0.5:
fails.append("leg 3: fixed build took %.3fs on a 2,000-node chain" % times["fixed"])
if "pre-fix" in times and times["pre-fix"] <= 1.0:
fails.append(
"leg 3: pre-fix build was fast (%.3fs) -- the quadratic claim is not reproducible"
% times["pre-fix"]
)
if len(times) == 2:
print(" ratio: %.0fx slower before the fix" % (times["pre-fix"] / times["fixed"]))
finally:
shutil.rmtree(tmp, ignore_errors=True)
if fails:
print("\nCHECK FAILED (%d):" % len(fails))
for f in fails:
print(" - %s" % f)
return 1
print("\nCHECK OK: spec green on the committed module, 4/4 sampled mutants killed, cost differential reproduced")
return 0
if __name__ == "__main__":
sys.exit(main())
+240
View File
@@ -0,0 +1,240 @@
#!/usr/bin/env python3
"""
mutate_teeth.py -- does spec_schedkit.py actually bite?
A green spec proves nothing on its own: a spec that asserts nothing is green
forever. This harness mutates schedkit.py and requires the spec to go red.
Three parts:
control the unmutated module must PASS (else the harness is broken)
baseline the pre-fix module (schedkit_v1_quadratic.py) must FAIL
mutants 12 single-substitution bugs, each of which the spec must catch
Each run happens in its own scratch directory with a copy of the module and a
copy of the spec, so the working tree is never touched.
Exit 0 == control passed, baseline failed, every mutant was caught.
Exit 1 == something survived; the line says what.
Usage: python3 mutate_teeth.py [--budget SECONDS]
"""
import os
import shutil
import subprocess
import sys
import tempfile
import time
HERE = os.path.dirname(os.path.abspath(__file__))
SPEC = os.path.join(HERE, "spec_schedkit.py")
MODULE = os.path.join(HERE, "schedkit.py")
BASELINE = os.path.join(HERE, "schedkit_v1_quadratic.py")
# (label, old, new). `old` must occur exactly once in schedkit.py.
MUTANTS = [
(
"plan: waves not sorted (the pre-fix behaviour)",
" return tuple(tuple(sorted(w)) for w in waves)",
" return tuple(tuple(w) for w in waves)",
),
(
"plan: wave depth uses max of deps, not 1+max",
" wave_of[t] = 1 + max((wave_of[d] for d in g.deps(t)), default=-1)",
" wave_of[t] = max((wave_of[d] for d in g.deps(t)), default=-1)",
),
(
"find_cycles: cycles not canonically rotated",
"lambda c: found.add(_canon(tuple(ids[comp[i]] for i in c))),",
"lambda c: found.add(tuple(ids[comp[i]] for i in c)),",
),
(
"find_cycles: self-loops dropped",
" if v in adj[v]:\n found.add((ids[v],))",
" if False:\n found.add((ids[v],))",
),
(
"find_cycles: singleton SCCs never checked for self-loops",
" if len(comp) == 1:",
" if False:",
),
(
"CIRCUIT: nodes never unblocked",
" if closed:\n _unblock(v, blocked, blocked_map)",
" if False:\n _unblock(v, blocked, blocked_map)",
),
(
"CIRCUIT: floor ignored (removed vertices stay visible)",
" if w < floor or w not in nodes:",
" if w not in nodes:",
),
(
"topological_order: heap replaced by list pop",
" t = heapq.heappop(ready)",
" t = ready.pop()",
),
(
"plan_json: n_waves off by one",
' "n_waves": len(waves),',
' "n_waves": len(waves) + 1,',
),
(
"iter_plan: drops the last task of every wave",
" for w in waves:\n for t in w:\n yield t",
" for w in waves:\n for t in w[:-1]:\n yield t",
),
(
"relabel: dependency ids not remapped",
" sorted(mapping.get(d, d) for d in self._deps[t]),",
" sorted(self._deps[t]),",
),
(
"PERF: Johnson's source-vertex skip removed (ring becomes O(k^2))",
" if indeg[s]: # a self-loop counts: it is an incoming edge",
" if True:",
),
]
def run_spec(workdir: str, budget: float):
"""Run the spec in `workdir`. Returns (rc|None, output, seconds)."""
t0 = time.monotonic()
try:
p = subprocess.run(
[sys.executable, "spec_schedkit.py"],
cwd=workdir,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=budget,
text=True,
)
return p.returncode, p.stdout, time.monotonic() - t0
except subprocess.TimeoutExpired as exc:
out = exc.stdout or ""
if isinstance(out, bytes):
out = out.decode("utf-8", "replace")
return None, out, time.monotonic() - t0
def first_failure(out: str) -> str:
lines = out.splitlines()
for i, ln in enumerate(lines):
if ln.startswith("FAILURES ("):
for nxt in lines[i + 1 : i + 4]:
if nxt.strip().startswith("- "):
return nxt.strip()[2:]
break
for ln in lines:
if ln.startswith("FAILURES"):
return ln
return "(no failure line)"
def summary(out: str) -> str:
for ln in reversed(out.splitlines()):
if " checks, " in ln and " failed" in ln:
return ln.strip()
return "(no summary line)"
def main() -> int:
budget = 300.0
if "--budget" in sys.argv:
budget = float(sys.argv[sys.argv.index("--budget") + 1])
for path in (SPEC, MODULE):
if not os.path.exists(path):
print("missing %s" % path)
return 1
print("spec : %s" % SPEC)
print("module : %s" % MODULE)
print("budget : %.0fs per run\n" % budget)
failures = []
# ------------------------------------------------------------- control
tmp = tempfile.mkdtemp(prefix="teeth-control-")
try:
shutil.copy(MODULE, os.path.join(tmp, "schedkit.py"))
shutil.copy(SPEC, os.path.join(tmp, "spec_schedkit.py"))
rc, out, dt = run_spec(tmp, budget)
print("CONTROL unmutated module rc=%s %5.1fs %s" % (rc, dt, summary(out)))
if rc != 0:
failures.append("control: the unmutated module failed the spec")
print(first_failure(out))
finally:
shutil.rmtree(tmp, ignore_errors=True)
# ------------------------------------------------------------ baseline
if os.path.exists(BASELINE):
tmp = tempfile.mkdtemp(prefix="teeth-baseline-")
try:
shutil.copy(BASELINE, os.path.join(tmp, "schedkit.py"))
shutil.copy(SPEC, os.path.join(tmp, "spec_schedkit.py"))
rc, out, dt = run_spec(tmp, min(budget, 120.0))
caught = rc is None or rc != 0
verdict = "did not finish in %.0fs" % dt if rc is None else "rc=%d" % rc
print(
"BASELINE pre-fix module (v1) %s %5.1fs %s"
% ("CAUGHT " if caught else "SURVIVED", dt, verdict)
)
if not caught:
failures.append("baseline: the pre-fix module passed the spec")
finally:
shutil.rmtree(tmp, ignore_errors=True)
# ------------------------------------------------------------- mutants
caught_n = 0
for i, (label, old, new) in enumerate(MUTANTS, 1):
src = open(MODULE).read()
if src.count(old) != 1:
failures.append("mutant %d anchor not unique (%d): %s" % (i, src.count(old), label))
print("M%-2d ANCHOR %s" % (i, label))
continue
tmp = tempfile.mkdtemp(prefix="teeth-m%02d-" % i)
try:
mutated = src.replace(old, new)
assert mutated != src
with open(os.path.join(tmp, "schedkit.py"), "w") as fh:
fh.write(mutated)
shutil.copy(SPEC, os.path.join(tmp, "spec_schedkit.py"))
rc, out, dt = run_spec(tmp, budget)
if rc is None:
caught_n += 1
print(
"M%-2d CAUGHT %-55s %5.0fs wall-clock gate (no exit)"
% (i, label, dt)
)
elif rc != 0:
caught_n += 1
print(
"M%-2d CAUGHT %-55s %5.1fs %s"
% (i, label, dt, first_failure(out))
)
else:
failures.append("mutant %d SURVIVED: %s" % (i, label))
print("M%-2d SURVIVED %s" % (i, label))
finally:
shutil.rmtree(tmp, ignore_errors=True)
print(
"\n%d/%d mutants caught, baseline %s, control %s"
% (
caught_n,
len(MUTANTS),
"caught" if os.path.exists(BASELINE) else "absent",
"passes" if not any(f.startswith("control") for f in failures) else "FAILS",
)
)
if failures:
print("\nTEETH FAILED (%d):" % len(failures))
for f in failures:
print(" - %s" % f)
return 1
print("TEETH OK: the spec is not a decoration")
return 0
if __name__ == "__main__":
sys.exit(main())
+513
View File
@@ -0,0 +1,513 @@
#!/usr/bin/env python3
"""
schedkit.py -- deterministic parallel scheduler for DAGs of agent tasks.
Lane: Codeforge (null).
A troupe schedules work as a graph: tasks with prerequisites. Given the same
graph, this module must always produce the SAME execution plan -- no threads,
no wall-clock, no set-iteration order leaking into the output. The plan is a
list of *waves*: the tasks that may start together, in lexicographic order.
Determinism contract (the reason this module exists):
plan(g) == plan(g) for every graph g
plan(g) == plan(relabel(g)) for every id permutation
Cycles are a *result*, not a crash: find_cycles() returns every distinct simple
cycle (canonically rotated, deduplicated, sorted), and plan() raises CycleError
carrying them.
Cost (measured, CPython 3.14, see spec_schedkit.py section G):
acyclic graph O(V+E) -- one SCC pass, no circuit search at all
one SCC of k O(k^2 + k*e_k + circuits) (Johnson's own bound)
Public API
----------
Graph graph container; add_task, add_dep, add_edge, copy, relabel
find_cycles(graph) -> tuple of cycles, each a tuple of task ids
plan(graph) -> tuple of waves (each wave a tuple of ids, sorted)
plan_graphviz(graph) -> Graphviz DOT source (str) with waves as ranks
plan_json(graph) -> JSON document (str) with waves, deps, validation
iter_plan(waves) -> flatten a plan in execution order
stdlib only. No I/O, no clock, no randomness.
"""
from __future__ import annotations
import heapq
import json
from typing import Dict, Iterable, Iterator, List, Sequence, Set, Tuple
__all__ = [
"Graph",
"CycleError",
"find_cycles",
"plan",
"plan_graphviz",
"plan_json",
"iter_plan",
]
TASK = str
CYCLE = Tuple[TASK, ...]
TASKS = Tuple[TASK, ...]
WAVES = Tuple[TASKS, ...]
class CycleError(ValueError):
"""Raised by plan() when the dependency graph contains a cycle."""
def __init__(self, cycles: Sequence[CYCLE]) -> None:
self.cycles: WAVES = tuple(tuple(c) for c in cycles)
shown = ", ".join(" -> ".join(list(c) + [c[0]]) for c in self.cycles[:3])
more = "" if len(self.cycles) <= 3 else " (+%d more)" % (len(self.cycles) - 3)
super().__init__("dependency cycle detected: %s%s" % (shown, more))
class Graph:
"""A directed dependency graph over string task ids.
deps[t] is the set of tasks that must complete before t may start.
Edges point prerequisite -> dependent.
"""
__slots__ = ("_deps", "_dependents")
def __init__(self) -> None:
self._deps: Dict[TASK, Set[TASK]] = {}
self._dependents: Dict[TASK, Set[TASK]] = {}
# ------------------------------------------------------------------ build
def add_task(self, task: TASK, deps: Iterable[TASK] = ()) -> "Graph":
if not isinstance(task, str):
raise TypeError("task id must be str, got %s" % type(task).__name__)
if not task:
raise ValueError("task id must be non-empty")
if task in self._deps and deps:
raise ValueError(
"add_task(%r, deps=...) would silently drop existing deps" % task
)
self._deps.setdefault(task, set())
self._dependents.setdefault(task, set())
for d in deps:
self.add_dep(task, d)
return self
def add_dep(self, task: TASK, prerequisite: TASK) -> "Graph":
self.add_task(task)
self.add_task(prerequisite)
self._deps[task].add(prerequisite)
self._dependents[prerequisite].add(task)
return self
def add_edge(self, prerequisite: TASK, task: TASK) -> "Graph":
return self.add_dep(task, prerequisite)
def add_edges(self, pairs: Iterable[Tuple[TASK, TASK]]) -> "Graph":
for pre, post in pairs:
self.add_edge(pre, post)
return self
# --------------------------------------------------------------- inspect
def tasks(self) -> TASKS:
return tuple(sorted(self._deps))
def deps(self, task: TASK) -> TASKS:
return tuple(sorted(self._deps[task]))
def dependents(self, task: TASK) -> TASKS:
return tuple(sorted(self._dependents.get(task, ())))
def edges(self) -> Tuple[Tuple[TASK, TASK], ...]:
return tuple(
(pre, t) for t in sorted(self._deps) for pre in sorted(self._deps[t])
)
def __contains__(self, task: object) -> bool:
return task in self._deps
def __len__(self) -> int:
return len(self._deps)
def __bool__(self) -> bool:
return bool(self._deps)
def __eq__(self, other: object) -> bool:
return isinstance(other, Graph) and self.edges() == other.edges()
def __hash__(self) -> int:
return hash(self.edges())
def __repr__(self) -> str:
return "Graph(%r)" % (self.edges(),)
# ---------------------------------------------------------------- derive
def copy(self) -> "Graph":
g = Graph()
for t in sorted(self._deps):
g.add_task(t, sorted(self._deps[t]))
return g
def relabel(self, mapping: Dict[TASK, TASK]) -> "Graph":
"""Return an isomorphic graph under an injective id mapping.
Unmapped tasks keep their id. Raises ValueError if the mapping collides.
"""
values = list(mapping.values())
if len(set(values)) != len(values):
raise ValueError("relabel mapping is not injective")
out = Graph()
for t in sorted(self._deps):
out.add_task(
mapping.get(t, t),
sorted(mapping.get(d, d) for d in self._deps[t]),
)
return out
def topological_order(self) -> TASKS:
"""Kahn's algorithm, min-id first: a deterministic linear extension.
Precisely: the *lexicographically smallest* topological order of the
graph. A min-heap over the ready set is what buys that -- popping the
largest ready id instead yields a valid order that is not the smallest,
so the heap is part of the contract, not an optimisation detail.
A heap, not a list scan. The previous `min(ready)` + `list.remove` made
a *wide* graph O(V^2): 20,000 tasks ready at once cost ~10s for a single
plan, and 5,000-wide layers cost ~12s.
"""
order: List[TASK] = []
pending = {t: set(self._deps[t]) for t in self._deps}
ready: List[TASK] = [t for t in self._deps if not pending[t]]
heapq.heapify(ready)
while ready:
t = heapq.heappop(ready)
order.append(t)
for d in sorted(self._dependents.get(t, ())):
pending[d].discard(t)
if not pending[d]:
heapq.heappush(ready, d)
if len(order) != len(self._deps):
raise CycleError(find_cycles(self))
return tuple(order)
# --------------------------------------------------------------------- cycles
def _sccs(adj, roots):
"""Iterative Tarjan. Every SCC of the graph, as sorted tuples of indices.
`adj[i]` is node i's successor list. Deterministic: roots are taken in the
given order and every adjacency list is sorted, so the returned list is
reproducible. Iterative throughout -- no recursion to blow up on a deep
graph.
"""
index_of: Dict[int, int] = {}
low: Dict[int, int] = {}
on_stack: Set[int] = set()
stack: List[int] = []
counter = 0
out: List[Tuple[int, ...]] = []
for root in roots:
if root in index_of:
continue
work: List[List[int]] = [[root, 0]]
while work:
v, pi = work[-1]
if pi == 0:
index_of[v] = low[v] = counter
counter += 1
stack.append(v)
on_stack.add(v)
recurse = False
succ = adj[v]
for i in range(pi, len(succ)):
w = succ[i]
if w not in index_of:
work[-1][1] = i + 1
work.append([w, 0])
recurse = True
break
if w in on_stack:
low[v] = min(low[v], index_of[w])
if recurse:
continue
if low[v] == index_of[v]:
comp: List[int] = []
while True:
w = stack.pop()
on_stack.discard(w)
comp.append(w)
if w == v:
break
out.append(tuple(sorted(comp)))
work.pop()
if work:
u = work[-1][0]
low[u] = min(low[u], low[v])
return out
def _scc_from(adj, root, floor):
"""The SCC containing `root` in the subgraph induced by nodes >= `floor`.
Neighbours below `floor` are invisible: that is Johnson's outer-loop vertex
removal done by index comparison -- O(1) per step -- instead of rewriting
every adjacency list, which cost O(E) per step and made the previous build
quadratic on graphs that contained no cycle at all.
"""
index_of: Dict[int, int] = {}
low: Dict[int, int] = {}
on_stack: Set[int] = set()
stack: List[int] = []
counter = 0
comp: List[int] = [root]
work: List[List[int]] = [[root, 0]]
while work:
v, pi = work[-1]
if pi == 0:
index_of[v] = low[v] = counter
counter += 1
stack.append(v)
on_stack.add(v)
recurse = False
succ = adj[v]
for i in range(pi, len(succ)):
w = succ[i]
if w < floor:
continue
if w not in index_of:
work[-1][1] = i + 1
work.append([w, 0])
recurse = True
break
if w in on_stack:
low[v] = min(low[v], index_of[w])
if recurse:
continue
if low[v] == index_of[v]:
got: List[int] = []
while True:
w = stack.pop()
on_stack.discard(w)
got.append(w)
if w == v:
break
if v == root:
comp = got
work.pop()
if work:
u = work[-1][0]
low[u] = min(low[u], low[v])
return comp
def _unblock(node, blocked, blocked_map) -> None:
todo = [node]
while todo:
n = todo.pop()
if n in blocked:
blocked.discard(n)
todo.extend(blocked_map.get(n, ()))
blocked_map[n] = set()
def _circuit(start, adj, nodes, floor, emit) -> None:
"""Johnson's CIRCUIT: every elementary circuit through `start` that stays
inside `nodes` and at or above `floor`.
Iterative: an explicit frame stack replaces the recursion, so a 50,000-node
ring cannot exhaust the interpreter stack.
"""
blocked: Set[int] = {start}
blocked_map: Dict[int, Set[int]] = {}
path: List[int] = [start]
frames: List[List[int]] = [[start, 0, False]] # vertex, scan position, closed
while frames:
fr = frames[-1]
v = fr[0]
succ = adj[v]
i = fr[1]
closed = fr[2]
descended = False
while i < len(succ):
w = succ[i]
i += 1
if w < floor or w not in nodes:
continue
if w == start:
emit(tuple(path))
closed = True
elif w not in blocked:
fr[1] = i
fr[2] = closed
path.append(w)
blocked.add(w)
frames.append([w, 0, False])
descended = True
break
if descended:
continue
fr[1] = i
fr[2] = closed
if closed:
_unblock(v, blocked, blocked_map)
else:
for w in succ:
if w >= floor and w in nodes:
blocked_map.setdefault(w, set()).add(v)
frames.pop()
path.pop()
if frames and closed:
frames[-1][2] = True
def _johnson(comp, adj, ids, found) -> None:
"""Every elementary circuit inside one SCC (`comp` = sorted indices).
Faithful Johnson: walk the local indices upward; at each step take the SCC
holding the least still-visible vertex and run CIRCUIT from it.
One addition to Johnson's own bookkeeping: a visible vertex with no visible
predecessor cannot lie on a cycle, so it is skipped without an SCC pass.
Without it a k-node ring costs O(k^2) (a Tarjan walk per node); with it the
ring costs O(k), because removing each node leaves its successor a source.
"""
local = {g: i for i, g in enumerate(comp)}
ladj = [[local[w] for w in adj[g] if w in local] for g in comp]
k = len(comp)
indeg = [0] * k
for row in ladj:
for w in row:
indeg[w] += 1
s = 0
while s < k:
if indeg[s]: # a self-loop counts: it is an incoming edge
sub = _scc_from(ladj, s, s)
if len(sub) > 1 or (len(sub) == 1 and s in ladj[s]):
nodes = set(sub)
_circuit(
s,
ladj,
nodes,
s,
lambda c: found.add(_canon(tuple(ids[comp[i]] for i in c))),
)
for w in ladj[s]: # remove s from the visible subgraph
indeg[w] -= 1
s += 1
def find_cycles(g: Graph) -> Tuple[CYCLE, ...]:
"""Return every distinct simple cycle: canonically rotated, deduped, sorted.
Johnson's algorithm, fronted by a single SCC pass: a vertex that cannot lie
on a cycle is never handed to the circuit search, so an acyclic graph costs
O(V+E). Cost inside one SCC of k vertices is O(k^2 + k*e_k + circuits),
Johnson's own bound.
Deterministic: same graph -> same tuple of cycles in the same order.
"""
ids = sorted(g._deps)
if not ids:
return ()
idx = {t: i for i, t in enumerate(ids)}
adj = [[idx[d] for d in g.dependents(t)] for t in ids]
found: Set[CYCLE] = set()
for comp in _sccs(adj, range(len(ids))):
if len(comp) == 1:
v = comp[0]
if v in adj[v]:
found.add((ids[v],))
continue
_johnson(comp, adj, ids, found)
return tuple(sorted(found))
def _canon(cycle: Sequence[TASK]) -> CYCLE:
"""Rotate a cycle so its lexicographically smallest node comes first."""
cyc = tuple(cycle)
if not cyc:
return cyc
i = min(range(len(cyc)), key=lambda k: (cyc[k], k))
return cyc[i:] + cyc[:i]
# ------------------------------------------------------------------------ plan
def plan(g: Graph) -> WAVES:
"""Return the deterministic parallel execution plan: a tuple of waves.
A task sits in wave k where k = 1 + max(wave of each prerequisite); tasks
with no prerequisites are in wave 0. Ids sort lexicographically within a
wave -- *sorted*, not merely "in topological pop order": wave membership is
the plan, and the order inside a wave must therefore depend on the ids
alone. Without that, plan(relabel(g)) was not the relabelled plan(g) and
the isomorphism contract in this module's header was false.
Raises CycleError if the graph is cyclic.
"""
if not g:
return ()
cycles = find_cycles(g)
if cycles:
raise CycleError(cycles)
order = g.topological_order() # deterministic linear extension
wave_of: Dict[TASK, int] = {}
for t in order:
wave_of[t] = 1 + max((wave_of[d] for d in g.deps(t)), default=-1)
n = max(wave_of.values()) + 1
waves: List[List[TASK]] = [[] for _ in range(n)]
for t in order:
waves[wave_of[t]].append(t)
return tuple(tuple(sorted(w)) for w in waves)
def plan_graphviz(g: Graph) -> str:
"""Graphviz DOT source for the plan; tasks in a wave share a rank."""
cycles = find_cycles(g)
if cycles:
raise CycleError(cycles)
waves = plan(g)
rank_of = {t: i for i, w in enumerate(waves) for t in w}
lines = ["digraph plan {", " rankdir=LR;", " node [shape=box];"]
for t in g.tasks():
lines.append(' "%s" [label="w%d: %s"];' % (t, rank_of[t], t))
for pre, post in g.edges():
lines.append(' "%s" -> "%s";' % (pre, post))
for w in waves:
lines.append(" { rank=same; %s }" % "; ".join('"%s"' % t for t in w))
lines.append("}")
return "\n".join(lines) + "\n"
def plan_json(g: Graph) -> str:
"""Machine-readable plan: waves, deps, validation, and the graph's edges."""
waves = plan(g)
rank_of = {t: i for i, w in enumerate(waves) for t in w}
doc = {
"tasks": [
{"id": t, "wave": rank_of[t], "deps": list(g.deps(t))}
for t in g.tasks()
],
"waves": [list(w) for w in waves],
"validation": {
"acyclic": True,
"n_tasks": len(g),
"n_waves": len(waves),
"edges": [list(e) for e in g.edges()],
},
}
return json.dumps(doc, indent=2) + "\n"
def iter_plan(waves: Iterable[Sequence[TASK]]) -> Iterator[TASK]:
"""Flatten a plan in execution order (wave by wave, id order inside)."""
for w in waves:
for t in w:
yield t
@@ -0,0 +1,384 @@
#!/usr/bin/env python3
"""
schedkit.py -- deterministic parallel scheduler for DAGs of agent tasks.
Lane: Codeforge (null).
A troupe schedules work as a graph: tasks with prerequisites. Given the same
graph, this module must always produce the SAME execution plan -- no threads,
no wall-clock, no set-iteration order leaking into the output. The plan is a
list of *waves*: the tasks that may start together, in lexicographic order.
Determinism contract (the reason this module exists):
plan(g) == plan(g) for every graph g
plan(g) == plan(relabel(g)) for every id permutation
Cycles are a *result*, not a crash: find_cycles() returns every distinct simple
cycle (canonically rotated, deduplicated, sorted), and plan() raises CycleError
carrying them.
Public API
----------
Graph graph container; add_task, add_dep, add_edge, copy, relabel
find_cycles(graph) -> tuple of cycles, each a tuple of task ids
plan(graph) -> tuple of waves (each wave a tuple of ids, sorted)
plan_graphviz(graph) -> Graphviz DOT source (str) with waves as ranks
plan_json(graph) -> JSON document (str) with waves, deps, validation
iter_plan(waves) -> flatten a plan in execution order
stdlib only. No I/O, no clock, no randomness.
"""
from __future__ import annotations
import json
from typing import Dict, Iterable, Iterator, List, Sequence, Set, Tuple
__all__ = [
"Graph",
"CycleError",
"find_cycles",
"plan",
"plan_graphviz",
"plan_json",
"iter_plan",
]
TASK = str
CYCLE = Tuple[TASK, ...]
TASKS = Tuple[TASK, ...]
WAVES = Tuple[TASKS, ...]
class CycleError(ValueError):
"""Raised by plan() when the dependency graph contains a cycle."""
def __init__(self, cycles: Sequence[CYCLE]) -> None:
self.cycles: WAVES = tuple(tuple(c) for c in cycles)
shown = ", ".join(" -> ".join(list(c) + [c[0]]) for c in self.cycles[:3])
more = "" if len(self.cycles) <= 3 else " (+%d more)" % (len(self.cycles) - 3)
super().__init__("dependency cycle detected: %s%s" % (shown, more))
class Graph:
"""A directed dependency graph over string task ids.
deps[t] is the set of tasks that must complete before t may start.
Edges point prerequisite -> dependent.
"""
__slots__ = ("_deps", "_dependents")
def __init__(self) -> None:
self._deps: Dict[TASK, Set[TASK]] = {}
self._dependents: Dict[TASK, Set[TASK]] = {}
# ------------------------------------------------------------------ build
def add_task(self, task: TASK, deps: Iterable[TASK] = ()) -> "Graph":
if not isinstance(task, str):
raise TypeError("task id must be str, got %s" % type(task).__name__)
if not task:
raise ValueError("task id must be non-empty")
if task in self._deps and deps:
raise ValueError(
"add_task(%r, deps=...) would silently drop existing deps" % task
)
self._deps.setdefault(task, set())
self._dependents.setdefault(task, set())
for d in deps:
self.add_dep(task, d)
return self
def add_dep(self, task: TASK, prerequisite: TASK) -> "Graph":
self.add_task(task)
self.add_task(prerequisite)
self._deps[task].add(prerequisite)
self._dependents[prerequisite].add(task)
return self
def add_edge(self, prerequisite: TASK, task: TASK) -> "Graph":
return self.add_dep(task, prerequisite)
def add_edges(self, pairs: Iterable[Tuple[TASK, TASK]]) -> "Graph":
for pre, post in pairs:
self.add_edge(pre, post)
return self
# --------------------------------------------------------------- inspect
def tasks(self) -> TASKS:
return tuple(sorted(self._deps))
def deps(self, task: TASK) -> TASKS:
return tuple(sorted(self._deps[task]))
def dependents(self, task: TASK) -> TASKS:
return tuple(sorted(self._dependents.get(task, ())))
def edges(self) -> Tuple[Tuple[TASK, TASK], ...]:
return tuple(
(pre, t) for t in sorted(self._deps) for pre in sorted(self._deps[t])
)
def __contains__(self, task: object) -> bool:
return task in self._deps
def __len__(self) -> int:
return len(self._deps)
def __bool__(self) -> bool:
return bool(self._deps)
def __eq__(self, other: object) -> bool:
return isinstance(other, Graph) and self.edges() == other.edges()
def __hash__(self) -> int:
return hash(self.edges())
def __repr__(self) -> str:
return "Graph(%r)" % (self.edges(),)
# ---------------------------------------------------------------- derive
def copy(self) -> "Graph":
g = Graph()
for t in sorted(self._deps):
g.add_task(t, sorted(self._deps[t]))
return g
def relabel(self, mapping: Dict[TASK, TASK]) -> "Graph":
"""Return an isomorphic graph under an injective id mapping.
Unmapped tasks keep their id. Raises ValueError if the mapping collides.
"""
values = list(mapping.values())
if len(set(values)) != len(values):
raise ValueError("relabel mapping is not injective")
out = Graph()
for t in sorted(self._deps):
out.add_task(
mapping.get(t, t),
sorted(mapping.get(d, d) for d in self._deps[t]),
)
return out
def topological_order(self) -> TASKS:
"""Kahn's algorithm, min-id first: a deterministic linear extension."""
order: List[TASK] = []
ready: List[TASK] = []
pending = {t: set(self._deps[t]) for t in self._deps}
for t in sorted(pending):
if not pending[t]:
ready.append(t)
while ready:
t = min(ready)
ready.remove(t)
order.append(t)
for d in sorted(self._dependents.get(t, ())):
pending[d].discard(t)
if not pending[d]:
ready.append(d)
if len(order) != len(self._deps):
raise CycleError(find_cycles(self))
return tuple(order)
# --------------------------------------------------------------------- cycles
def _strongly_connected(adj: Dict[TASK, List[TASK]], nodes: Sequence[TASK]) -> Set[TASK]:
"""Tarjan's SCC algorithm, iterative.
Returns the vertices of the first SCC with more than one vertex, or any
self-loop, in the subgraph induced by `nodes`. Empty set if none.
"""
index_of: Dict[TASK, int] = {}
low: Dict[TASK, int] = {}
on_stack: Set[TASK] = set()
stack: List[TASK] = []
counter = 0
for root in nodes:
if root in index_of:
continue
work: List[Tuple[TASK, int]] = [(root, 0)]
while work:
v, pi = work[-1]
if pi == 0:
index_of[v] = low[v] = counter
counter += 1
stack.append(v)
on_stack.add(v)
recurse = False
succ = adj[v]
for i in range(pi, len(succ)):
w = succ[i]
if w not in index_of:
work[-1] = (v, i + 1)
work.append((w, 0))
recurse = True
break
if w in on_stack:
low[v] = min(low[v], index_of[w])
if recurse:
continue
if low[v] == index_of[v]:
comp: List[TASK] = []
while True:
w = stack.pop()
on_stack.discard(w)
comp.append(w)
if w == v:
break
if len(comp) > 1 or (len(comp) == 1 and comp[0] in adj[comp[0]]):
return set(comp)
work.pop()
if work:
u, _ = work[-1]
low[u] = min(low[u], low[v])
return set()
def find_cycles(g: Graph) -> Tuple[CYCLE, ...]:
"""Return every distinct simple cycle: canonically rotated, deduped, sorted.
Johnson's algorithm -- enumerate elementary circuits without repetition.
Iterative throughout, so a 100k-node graph cannot exhaust the interpreter
stack. Deterministic: same graph -> same tuple of cycles in the same order.
"""
adj: Dict[TASK, List[TASK]] = {t: sorted(g.dependents(t)) for t in sorted(g._deps)}
ids = sorted(adj)
if not ids:
return ()
found: Set[CYCLE] = set()
def _unblock(node: TASK, blocked: Set[TASK], blocked_map: Dict[TASK, Set[TASK]]) -> None:
todo = [node]
while todo:
n = todo.pop()
if n in blocked:
blocked.discard(n)
todo.extend(sorted(blocked_map.get(n, ())))
blocked_map[n] = set()
def _circuit(start: TASK, adj_s: Dict[TASK, List[TASK]], nodes: Set[TASK]) -> bool:
blocked: Set[TASK] = set()
blocked_map: Dict[TASK, Set[TASK]] = {v: set() for v in nodes}
stack: List[TASK] = []
found_here: List[CYCLE] = []
def _walk(v: TASK) -> bool:
closed = False
stack.append(v)
blocked.add(v)
for w in adj_s[v]:
if w == start:
found_here.append(tuple(stack))
closed = True
elif w not in blocked and w in nodes:
if _walk(w):
closed = True
if closed:
_unblock(v, blocked, blocked_map)
else:
for w in adj_s[v]:
if w in nodes:
blocked_map.setdefault(w, set()).add(v)
stack.pop()
return closed
_walk(start)
found.update(_canon(c) for c in found_here)
return bool(found_here)
remaining: Dict[TASK, List[TASK]] = dict(adj)
for s in ids:
if s not in remaining:
continue
sub_nodes = set(_strongly_connected(remaining, [s]))
if sub_nodes:
_circuit(s, remaining, sub_nodes)
# Remove s from the graph for all subsequent iterations (Johnson 1975).
del remaining[s]
for v in remaining:
if s in remaining[v]:
remaining[v] = [w for w in remaining[v] if w != s]
return tuple(sorted(found))
def _canon(cycle: Sequence[TASK]) -> CYCLE:
"""Rotate a cycle so its lexicographically smallest node comes first."""
cyc = tuple(cycle)
if not cyc:
return cyc
i = min(range(len(cyc)), key=lambda k: (cyc[k], k))
return cyc[i:] + cyc[:i]
# ------------------------------------------------------------------------ plan
def plan(g: Graph) -> WAVES:
"""Return the deterministic parallel execution plan: a tuple of waves.
A task sits in wave k where k = 1 + max(wave of each prerequisite); tasks
with no prerequisites are in wave 0. Ids sort lexicographically within a
wave. Raises CycleError if the graph is cyclic.
"""
if not g:
return ()
cycles = find_cycles(g)
if cycles:
raise CycleError(cycles)
order = g.topological_order() # deterministic linear extension
wave_of: Dict[TASK, int] = {}
for t in order:
wave_of[t] = 1 + max((wave_of[d] for d in g.deps(t)), default=-1)
n = max(wave_of.values()) + 1
waves: List[List[TASK]] = [[] for _ in range(n)]
for t in order:
waves[wave_of[t]].append(t)
return tuple(tuple(w) for w in waves)
def plan_graphviz(g: Graph) -> str:
"""Graphviz DOT source for the plan; tasks in a wave share a rank."""
cycles = find_cycles(g)
if cycles:
raise CycleError(cycles)
waves = plan(g)
rank_of = {t: i for i, w in enumerate(waves) for t in w}
lines = ["digraph plan {", " rankdir=LR;", " node [shape=box];"]
for t in g.tasks():
lines.append(' "%s" [label="w%d: %s"];' % (t, rank_of[t], t))
for pre, post in g.edges():
lines.append(' "%s" -> "%s";' % (pre, post))
for w in waves:
lines.append(" { rank=same; %s }" % "; ".join('"%s"' % t for t in w))
lines.append("}")
return "\n".join(lines) + "\n"
def plan_json(g: Graph) -> str:
"""Machine-readable plan: waves, deps, validation, and the graph's edges."""
waves = plan(g)
rank_of = {t: i for i, w in enumerate(waves) for t in w}
doc = {
"tasks": [
{"id": t, "wave": rank_of[t], "deps": list(g.deps(t))}
for t in g.tasks()
],
"waves": [list(w) for w in waves],
"validation": {
"acyclic": True,
"n_tasks": len(g),
"n_waves": len(waves),
"edges": [list(e) for e in g.edges()],
},
}
return json.dumps(doc, indent=2) + "\n"
def iter_plan(waves: Iterable[Sequence[TASK]]) -> Iterator[TASK]:
"""Flatten a plan in execution order (wave by wave, id order inside)."""
for w in waves:
for t in w:
yield t
+600
View File
@@ -0,0 +1,600 @@
#!/usr/bin/env python3
"""
spec_schedkit.py -- executable specification for schedkit.py.
Exit 0 == every contract held.
Exit 1 == at least one contract failed (the failure is printed).
Contract classes
A. fixture cases hand-computed waves, printed in full
B. invariant checks every output re-derived from first principles
C. oracle check find_cycles against an independent brute-force enumeration
D. randomized 1,000 DAGs x 40 permutations, checked against the oracle
E. determinism repeated calls, build-order shuffles, deep graphs
F. cycle handling exact CycleError messages, DOT/JSON refusal
G. scale 50,000 tasks, 100,000 edges, under a wall-clock budget
H. cost shape acyclic graphs must be linear; a 50,000-node ring must
be walked iteratively (the recursive build died at 2,000)
I. linear extension topological_order() must return the lexicographically
SMALLEST topological order -- the documented "min-id
first" contract -- checked against brute force over all
permutations, not against the module's own heap
The randomized section is the teeth: it compares this implementation against a
*different* algorithm (brute-force cycle enumeration over permutations), not
against itself. A tautological test suite cannot pass this file.
"""
import itertools
import json
import os
import random
import sys
import time
from typing import Dict, List, Sequence, Set, Tuple
# Import the schedkit *beside this file*, not one from a hard-coded path: the
# mutation harness copies both files into a scratch dir and mutates the copy.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import schedkit # noqa: E402
from schedkit import CycleError, Graph, find_cycles, iter_plan, plan # noqa: E402
FAILS: List[str] = []
CHECKS = 0
T_START = time.monotonic()
def check(name: str, got, want) -> None:
global CHECKS
CHECKS += 1
if got != want:
FAILS.append("%s\n got : %r\n want: %r" % (name, got, want))
def check_true(name: str, cond: bool, detail: str = "") -> None:
global CHECKS
CHECKS += 1
if not cond:
FAILS.append("%s %s" % (name, detail))
def section(title: str) -> None:
print("\n== %s" % title)
# =========================================================== A. fixture cases
def canon_ids(cyc: Sequence[str]) -> Tuple[str, ...]:
"""Rotate a cycle so its lexicographically smallest id comes first.
Mirrors schedkit._canon, but written here from the *contract* (a cycle is a
ring, so any rotation names it) rather than copied from the module.
"""
if not cyc:
return tuple(cyc)
i = min(range(len(cyc)), key=lambda k: (cyc[k], k))
return tuple(cyc[i:]) + tuple(cyc[:i])
def build(edges, extra_tasks=()):
g = Graph()
for t in extra_tasks:
g.add_task(t)
return g.add_edges(edges)
def fixtures() -> None:
section("A. fixtures")
# A1: diamond. a -> b, a -> c, b -> d, c -> d
g = build([("a", "b"), ("a", "c"), ("b", "d"), ("c", "d")])
check("A1 diamond waves", plan(g), (("a",), ("b", "c"), ("d",)))
# A2: independent chains do not wait on each other
g = build([("a1", "a2"), ("b1", "b2"), ("a2", "a3")])
check("A2 two chains", plan(g), (("a1", "b1"), ("a2", "b2"), ("a3",)))
# A3: empty graph
check("A3 empty", plan(Graph()), ())
# A4: single task, no deps
check("A4 single", plan(build([], ["solo"])), (("solo",),))
# A5: lexicographic order inside a wave (insertion order is reversed)
g = Graph()
for t in ["zeta", "alpha", "mike", "bravo"]:
g.add_task(t)
check("A5 wave order", plan(g), (("alpha", "bravo", "mike", "zeta"),))
# A6: a long chain gives one wave per task
g = build([(chr(97 + i), chr(98 + i)) for i in range(5)])
check("A6 chain of 6", plan(g), tuple((c,) for c in "abcdef"))
# A7: wave depth is prerequisite-depth, not "connected component"
g = build([("root", "x"), ("root", "y"), ("x", "z"), ("y", "z"), ("unrelated", "u2")])
check("A7 depth", plan(g), (("root", "unrelated"), ("u2", "x", "y"), ("z",)))
# A8: the graph is untouched by planning (no mutation)
g = build([("a", "b"), ("b", "c")])
before = g.edges()
plan(g)
check("A8 no mutation", g.edges(), before)
# A9: duplicate edge is idempotent
g = Graph()
g.add_edge("a", "b").add_edge("a", "b").add_edge("a", "b")
check("A9 idempotent edges", g.edges(), (("a", "b"),))
# A10: self-loop is a cycle, not a crash
g = Graph()
g.add_task("self", ["self"])
check("A10 self loop", find_cycles(g), (("self",),))
# ====================================================== B. invariant checks
def check_invariants(name: str, g: Graph) -> None:
waves = plan(g)
flat = tuple(iter_plan(waves))
# B1 partition: every task exactly once, in the graph
check("%s: partition" % name, tuple(sorted(flat)), g.tasks())
# B2 waves sorted ascending
for i, w in enumerate(waves):
check("%s: wave %d sorted" % (name, i), list(w), sorted(w))
# B3 acyclicity of the emitted plan order
pos = {t: i for i, t in enumerate(flat)}
for pre, post in g.edges():
check_true(
"%s: order respects %s->%s" % (name, pre, post),
pos[pre] < pos[post],
"(pos %d !< %d)" % (pos[pre], pos[post]),
)
# B4 wave of t == 1 + max(wave of deps); 0 if no deps
rank = {t: i for i, w in enumerate(waves) for t in w}
for t in g.tasks():
want = 1 + max((rank[d] for d in g.deps(t)), default=-1)
check("%s: rank(%s)" % (name, t), rank[t], want)
# B5 waves are contiguous and non-empty
check_true("%s: non-empty waves" % name, all(waves), str(waves))
# B6 plan_json is valid JSON and agrees with plan()
doc = json.loads(schedkit.plan_json(g))
check("%s: json waves" % name, tuple(tuple(w) for w in doc["waves"]), waves)
check("%s: json n_tasks" % name, doc["validation"]["n_tasks"], len(g))
def invariants() -> None:
section("B. invariants")
cases = {
"diamond": build([("a", "b"), ("a", "c"), ("b", "d"), ("c", "d")]),
"star": build([("h", "a"), ("h", "b"), ("h", "c"), ("h", "d")]),
"empty": Graph(),
"single": build([], ["only"]),
"mesh": build(
[("a", "b"), ("a", "c"), ("b", "d"), ("c", "d"), ("a", "d"), ("b", "c")]
),
"two-roots": build([("r1", "x"), ("r2", "x"), ("x", "y")]),
"fan-out-then-fan-in": build(
[("r", "a"), ("r", "b"), ("r", "c"), ("a", "z"), ("b", "z"), ("c", "z")]
),
}
for name, g in cases.items():
check_invariants(name, g)
# ============================================================= C. oracle check
def oracle_cycles(g: Graph) -> Tuple[Tuple[str, ...], ...]:
"""Independent brute force: DFS from every node, enumerate simple cycles.
Deliberately a different algorithm from schedkit.find_cycles (plain DFS with
path tracking rather than Johnson + Tarjan), so agreement is real evidence.
"""
adj = {t: sorted(g.dependents(t)) for t in g.tasks()}
found: Set[Tuple[str, ...]] = set()
def canon(cyc: List[str]) -> Tuple[str, ...]:
i = min(range(len(cyc)), key=lambda k: cyc[k])
return tuple(cyc[i:] + cyc[:i])
for start in sorted(adj):
stack: List[Tuple[str, List[str]]] = [(start, [start])]
while stack:
node, path = stack.pop()
for nxt in adj[node]:
if nxt == start and len(path) >= 1:
found.add(canon(path))
elif nxt not in path:
stack.append((nxt, path + [nxt]))
return tuple(sorted(found))
def oracle() -> None:
section("C. oracle: find_cycles vs brute-force DFS")
cases = {
"acyclic": build([("a", "b"), ("b", "c"), ("a", "c")]),
"one-cycle": build([("a", "b"), ("b", "c"), ("c", "a")]),
"two-cycles": build(
[("a", "b"), ("b", "a"), ("x", "y"), ("y", "x"), ("a", "x")]
),
"nested": build(
[("a", "b"), ("b", "c"), ("c", "a"), ("b", "d"), ("d", "a")]
),
"self-loop": (lambda: (lambda g: (g.add_task("s", ["s"]), g)[1])(Graph()))(),
"figure-eight": build(
[("a", "b"), ("b", "a"), ("b", "c"), ("c", "b"), ("a", "d"), ("d", "a")]
),
"chorded": build(
[("a", "b"), ("b", "c"), ("c", "d"), ("d", "a"), ("a", "c"), ("b", "d")]
),
"empty": Graph(),
}
for name, g in cases.items():
check("C oracle %s" % name, find_cycles(g), oracle_cycles(g))
# ============================================================ D. randomized
def random_dag(rng: random.Random, n: int, p: float) -> Graph:
"""Random DAG: edge i->j only when i<j, so acyclicity is by construction."""
g = Graph()
ids = ["t%03d" % i for i in range(n)]
for t in ids:
g.add_task(t)
for i in range(n):
for j in range(i + 1, n):
if rng.random() < p:
g.add_edge(ids[i], ids[j])
return g
def randomized(trials: int = 1000, perms: int = 40, seed: int = 20261021) -> None:
section("D. randomized differential (%d DAGs x %d permutations)" % (trials, perms))
rng = random.Random(seed)
t0 = time.monotonic()
oracle_calls = 0
for trial in range(trials):
n = rng.randint(0, 7)
p = rng.choice([0.0, 0.05, 0.15, 0.3, 0.6, 1.0])
g = random_dag(rng, n, p)
ids = g.tasks()
base = plan(g)
base_cycles = find_cycles(g)
# D1: oracle agreement on cycles (small graphs only -- brute force)
if n <= 6:
check("D1 trial %d cycles" % trial, base_cycles, oracle_cycles(g))
oracle_calls += 1
# D2: the plan is a permutation of the tasks
check("D2 trial %d partition" % trial, tuple(sorted(iter_plan(base))), ids)
# D3: isomorphism invariance under `perms` random relabellings.
# The relabelled plan must equal the original plan with every id
# substituted and the wave re-sorted -- a different id set is the whole
# point of the check, and wave order is defined by id order, not by the
# order the original ids happened to sit in.
for k in range(perms):
perm = list(ids)
rng.shuffle(perm)
mapping = {old: "n%03d" % i for i, old in enumerate(perm)}
g2 = g.relabel(mapping)
want_plan = tuple(
tuple(sorted(mapping[t] for t in w)) for w in base
)
check("D3 trial %d perm %d" % (trial, k), plan(g2), want_plan)
want_cycles = tuple(
sorted(canon_ids([mapping[t] for t in c]) for c in base_cycles)
)
check("D3c trial %d perm %d cycles" % (trial, k), find_cycles(g2), want_cycles)
check("D3b trial %d perm %d task count" % (trial, k), len(g2), len(g))
# D4: every edge is respected by the emitted order
flat = list(iter_plan(base))
pos = {t: i for i, t in enumerate(flat)}
for pre, post in g.edges():
check_true(
"D4 trial %d edge %s->%s" % (trial, pre, post),
pos[pre] < pos[post],
)
dt = time.monotonic() - t0
print(" %d DAGs, %d oracle comparisons, %.3fs" % (trials, oracle_calls, dt))
# ============================================================== E. determinism
def determinism() -> None:
section("E. determinism")
edges = [("a", "b"), ("b", "c"), ("a", "c"), ("d", "e"), ("e", "c"), ("f", "b")]
g = build(edges)
runs = [plan(g) for _ in range(200)]
check_true("E1 200 runs identical", all(r == runs[0] for r in runs))
# build-order shuffle must not change the plan
rng = random.Random(7)
outs = set()
for _ in range(50):
shuffled = list(edges)
rng.shuffle(shuffled)
gg = Graph()
for pre, post in shuffled:
gg.add_edge(pre, post)
outs.add(plan(gg))
check("E2 build order irrelevant", outs, {plan(build(edges))})
# deep graph must not blow the interpreter stack
deep = Graph()
N = 50000
for i in range(N - 1):
deep.add_edge("d%06d" % i, "d%06d" % (i + 1))
t0 = time.monotonic()
waves = plan(deep)
dt = time.monotonic() - t0
check("E3 deep chain waves", len(waves), N)
check("E3b deep chain last", waves[-1], ("d%06d" % (N - 1),))
print(" deep chain: %d tasks, %d waves, %.3fs" % (N, len(waves), dt))
# wide graph: one root, 20k leaves
wide = Graph()
for i in range(20000):
wide.add_edge("root", "leaf%05d" % i)
t0 = time.monotonic()
w = plan(wide)
dt = time.monotonic() - t0
check("E4 wide waves", (len(w), len(w[0]), len(w[1])), (2, 1, 20000))
print(" wide graph: 20001 tasks, %d waves, %.3fs" % (len(w), dt))
# build-order shuffle of a deep chain (insertion order reversed)
rev = Graph()
for i in range(N - 1, 0, -1):
rev.add_edge("d%06d" % (i - 1), "d%06d" % i)
check("E5 deep reversed identical", plan(rev), waves)
# =========================================================== F. cycle handling
def cycle_handling() -> None:
section("F. cycle handling")
g = build([("a", "b"), ("b", "c"), ("c", "a")])
try:
plan(g)
check_true("F1 CycleError raised", False, "no exception")
except CycleError as e:
check("F1 CycleError cycles", e.cycles, (("a", "b", "c"),))
check("F1b message", str(e), "dependency cycle detected: a -> b -> c -> a")
# canonical rotation: same cycle entered from a different node
g2 = build([("c", "a"), ("a", "b"), ("b", "c")])
check("F2 canonical rotation", find_cycles(g2), (("a", "b", "c"),))
# DOT and JSON must refuse a cyclic graph, not emit a broken plan
for label, fn in (("DOT", schedkit.plan_graphviz), ("JSON", schedkit.plan_json)):
try:
fn(g)
check_true("F3 %s refuses cycles" % label, False, "no exception")
except CycleError:
check_true("F3 %s refuses cycles" % label, True)
# two disjoint cycles are both reported
g3 = build([("a", "b"), ("b", "a"), ("x", "y"), ("y", "x")])
check("F4 two cycles", find_cycles(g3), (("a", "b"), ("x", "y")))
# a cycle reachable from an acyclic region still blocks the whole plan
g4 = build([("root", "a"), ("a", "b"), ("b", "a")])
try:
plan(g4)
check_true("F5 reachable cycle blocks", False, "no exception")
except CycleError:
check_true("F5 reachable cycle blocks", True)
# ==================================================================== G. scale
def scale() -> None:
section("G. scale")
budget = 20.0
t0 = time.monotonic()
# layered graph: 50k tasks, 100k edges, 10 layers
L, W = 10, 5000
g = Graph()
layers = [["L%d_%05d" % (l, i) for i in range(W)] for l in range(L)]
for l in layers:
for t in l:
g.add_task(t)
for l in range(L - 1):
for i in range(W):
for k in range(2): # 2 edges per task -> 100k edges
g.add_edge(layers[l][i], layers[l + 1][(i + k) % W])
t1 = time.monotonic()
waves = plan(g)
t2 = time.monotonic()
check("G1 layered waves", (len(waves), tuple(len(w) for w in waves)), (L, tuple([W] * L)))
check("G2 edge count", len(g.edges()), W * 2 * (L - 1))
print(
" %d tasks, %d edges: build %.3fs, plan %.3fs"
% (len(g), len(g.edges()), t1 - t0, t2 - t1)
)
check_true("G3 under budget", (t2 - t0) < budget, "(%.2fs >= %.0fs)" % (t2 - t0, budget))
# relabeling 50k tasks must also be linear-ish
t3 = time.monotonic()
ids = g.tasks()
mapping = {t: "R" + t[1:] for t in ids}
g2 = g.relabel(mapping)
w2 = plan(g2)
t4 = time.monotonic()
check("G4 relabel plan", [tuple(sorted(mapping[t] for t in w)) for w in waves],
[tuple(sorted(t for t in w)) for w in w2])
print(" relabel+plan 50k: %.3fs" % (t4 - t3))
check_true("G5 relabel under budget", (t4 - t3) < budget, "(%.2fs)" % (t4 - t3))
# ========================================== H. cost shape (why the build was fixed)
def shapes() -> None:
section("H. cost shape: acyclic graphs linear, big SCCs iterative")
# H1: a 2,000-node chain contains no cycle at all. The previous build took
# 5.6651s here (measured, quadratic); a linear one takes milliseconds. This
# gate catches a relapse into re-scanning every adjacency list per vertex.
chain = Graph()
for i in range(1999):
chain.add_edge("d%06d" % i, "d%06d" % (i + 1))
t0 = time.monotonic()
check("H1 chain has no cycles", find_cycles(chain), ())
dt = time.monotonic() - t0
print(" chain 2000: %.4fs (quadratic build measured 5.6651s)" % dt)
check_true("H1b chain under 0.5s", dt < 0.5, "(%.3fs)" % dt)
# H2: one 50,000-node ring is one SCC. Exactly one cycle, and it is found
# without recursing -- the recursive build raised RecursionError at n=2000
# with the default limit of 1000.
n = 50000
ring = Graph()
for i in range(n):
ring.add_edge("r%06d" % i, "r%06d" % ((i + 1) % n))
t0 = time.monotonic()
cyc = find_cycles(ring)
dt = time.monotonic() - t0
check("H2 ring one cycle", (len(cyc), len(cyc[0])), (1, n))
check("H2b ring is the ring", cyc[0], tuple("r%06d" % i for i in range(n)))
print(" ring 50000: %.3fs, recursionlimit %d" % (dt, sys.getrecursionlimit()))
check_true("H2c ring under 10s", dt < 10.0, "(%.2fs)" % dt)
# H3: 2,000 disjoint 5-rings -- every cycle reported, one SCC pass total.
many = Graph()
for r in range(2000):
ids = ["g%04d_%d" % (r, i) for i in range(5)]
for i in range(5):
many.add_edge(ids[i], ids[(i + 1) % 5])
t0 = time.monotonic()
c = find_cycles(many)
dt = time.monotonic() - t0
check("H3 disjoint rings", len(c), 2000)
print(" 2000 disjoint 5-rings: %d cycles, %.3fs" % (len(c), dt))
# ========================================= I. the linear extension's own contract
def smallest_topo_oracle(g: Graph) -> Tuple[str, ...]:
"""The lexicographically smallest topological order, by brute force.
Independent of the module and of any heap: walk every permutation of the
task ids in lexicographic order (itertools yields them sorted when the input
is sorted) and return the first one that respects every edge. Small graphs
only -- n! is the cost.
"""
ids = g.tasks()
deps = {t: set(g.deps(t)) for t in ids}
for perm in itertools.permutations(ids):
seen: Set[str] = set()
for t in perm:
if not deps[t] <= seen:
break
seen.add(t)
else:
return perm
return ()
def is_linear_extension(order: Sequence[str], g: Graph) -> bool:
if sorted(order) != list(g.tasks()):
return False
pos = {t: i for i, t in enumerate(order)}
return all(pos[pre] < pos[post] for pre, post in g.edges())
def linear_extension() -> None:
section("I. topological_order is the SMALLEST linear extension")
# I1: hand-computed. Kahn with a min-heap emits a, b, c, d; a plain stack
# (`ready.pop()`) emits a, c, b, d -- the same valid order, but not the
# smallest one, which is what the module's docstring promises.
g = build([("a", "b"), ("a", "c"), ("b", "d"), ("c", "d")])
check("I1 diamond topo", g.topological_order(), ("a", "b", "c", "d"))
# I2: tie-break is by id, not by insertion order or by stack position
g = build([("root", "zeta"), ("root", "alpha"), ("root", "mike")])
check("I2 tie-break by id", g.topological_order(), ("root", "alpha", "mike", "zeta"))
# I3: two ready roots -- the smaller one goes first, its whole chain too
g = build([("b1", "b2"), ("a1", "a2")])
check("I3 two roots", g.topological_order(), ("a1", "a2", "b1", "b2"))
# I4: chain and single task
g = build([(chr(97 + i), chr(98 + i)) for i in range(5)])
check("I4 chain topo", g.topological_order(), tuple("abcdef"))
check("I4b single", build([], ["solo"]).topological_order(), ("solo",))
check("I4c empty", Graph().topological_order(), ())
# I5: differential against brute force over all permutations, 300 random DAGs
rng = random.Random(4242)
checked = 0
for trial in range(300):
n = rng.randint(0, 6)
p = rng.choice([0.0, 0.1, 0.25, 0.5, 0.8, 1.0])
gg = random_dag(rng, n, p)
want = smallest_topo_oracle(gg)
check("I5 trial %d smallest linear extension" % trial, gg.topological_order(), want)
checked += 1
print(" %d random DAGs compared against all-permutations brute force" % checked)
# I6: insertion order must not matter -- ids alone decide the order
edges = [("a", "b"), ("a", "c"), ("c", "d"), ("b", "e"), ("d", "e"), ("z", "e")]
outs = set()
for _ in range(50):
shuffled = list(edges)
rng.shuffle(shuffled)
outs.add(build(shuffled).topological_order())
check("I6 build order irrelevant", outs, {build(edges).topological_order()})
# I7: still a valid linear extension at a size brute force cannot reach
big = Graph()
for i in range(3000):
big.add_task("t%04d" % i)
for i in range(0, 2999, 3):
big.add_edge("t%04d" % i, "t%04d" % (i + 1))
order = big.topological_order()
check_true("I7 3000-node order valid", is_linear_extension(order, big))
check("I7b 3000-node order size", len(order), 3000)
# I8: a cyclic graph must raise, not silently truncate the order
cyc = build([("a", "b"), ("b", "a")])
try:
cyc.topological_order()
check_true("I8 cyclic raises", False, "no exception")
except CycleError:
check_true("I8 cyclic raises", True)
# ======================================================================= main
def main() -> int:
print("schedkit spec -- %s" % schedkit.__file__)
print("python %s" % sys.version.split()[0])
fixtures()
invariants()
oracle()
randomized()
determinism()
cycle_handling()
scale()
shapes()
linear_extension()
dt = time.monotonic() - T_START
print("\n%d checks, %d failed, %.2fs" % (CHECKS, len(FAILS), dt))
if FAILS:
print("\nFAILURES (%d):" % len(FAILS))
for f in FAILS[:15]:
print(" - " + f)
if len(FAILS) > 15:
print(" ... and %d more" % (len(FAILS) - 15))
return 1
print("SPEC OK: all %d checks held" % CHECKS)
return 0
if __name__ == "__main__":
sys.exit(main())