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:
1 parent
fbcadf6ea7
commit
ca8e2b77ab
23 files changed
+5659
No files matched your search
Whitespace-only changes.
@@ -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.
|
||||
@@ -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"}]}}}
|
||||
}
|
||||
|
||||
]
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
@@ -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`.
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user