Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ repository = "https://github.com/wrightkit/wright"

[workspace.dependencies]
# Canonical Workshop core.
workshop-rs = "1.12.0"
workshop-rs = "1.16.0"
libc = "0.2"
serde = "1"
serde_json = "1"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ wright check [INPUT] # correctness diagnostics
wright lint [INPUT] # lint findings
wright analyze [INPUT] # cost, hotspots, and risk indicators
wright inspect [INPUT] # semantic summary
wright inspect symbols|refs|cfg|callgraph|cost
wright inspect rules|symbols|refs|cfg|callgraph|cost
wright rename OLD NEW [INPUT] # previews a diff; --write applies it
wright compile [INPUT] # emit Workshop text
wright convert [INPUT] --target opy
Expand Down
83 changes: 83 additions & 0 deletions benchmarks/agent/bench_grade.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,87 @@ def compiled_text(state: dict, source: str) -> str | None:
return (state["scratch"] / "wright.txt").read_text() if auth["wrightCompile"]["status"] == "ok" else None


def compiled_path(state: dict, source: str) -> Path | None:
"""Path of the compiled Workshop text from `wright` or the upstream `oracle`, when that authority succeeded."""
return (state["scratch"] / f"{source}.txt") if compiled_text(state, source) is not None else None


def rule_content(wright: str, program: Path, max_rules: int) -> dict:
"""The program's rules with canonical content (ADR-0024), read from raw Workshop so no `names` are involved."""
proc = subprocess.run([wright, "serve", "--kind", "workshop", str(program)],
input=json.dumps({"op": "rules", "content": True, "max": max_rules}) + "\n", capture_output=True, text=True)
try:
return json.loads(proc.stdout.splitlines()[0])
except (IndexError, json.JSONDecodeError):
return {"error": {"code": "harness-output", "message": proc.stderr.strip()}}


def pattern_matches(pattern, node) -> bool:
"""A pattern is a rule-content node: numbers compare within 1e-9, objects as a recursive subset, `{"any": [...]}` as alternatives."""
if isinstance(pattern, dict) and set(pattern) == {"any"}:
return any(pattern_matches(alternative, node) for alternative in pattern["any"])
if isinstance(pattern, dict):
return isinstance(node, dict) and all(k in node and pattern_matches(v, node[k]) for k, v in pattern.items())
if isinstance(pattern, list):
return isinstance(node, list) and len(pattern) == len(node) and all(pattern_matches(p, n) for p, n in zip(pattern, node))
if isinstance(pattern, bool) or isinstance(node, bool) or pattern is None or node is None:
return pattern is node
if isinstance(pattern, (int, float)):
return isinstance(node, (int, float)) and abs(pattern - node) <= 1e-9
return pattern == node


def call_nodes(node):
"""Every call node in one statement or value tree, outermost first."""
if isinstance(node, dict) and "call" in node:
yield node
for argument in node.get("args", []):
yield from call_nodes(argument)


def count_calls(rules: list[dict], check: dict) -> int:
"""Calls matching `call` and the positional `args` patterns, across every enabled rule (or those with the `event` id)."""
count = 0
for rule in rules:
content = rule["content"]
if content.get("disabled") or "event" in check and content["event"]["id"] != check["event"]:
continue
statements = [
condition["value"] for condition in content["conditions"] if not condition.get("disabled")
] + [action for action in content["actions"] if not action.get("disabled")]
for statement in statements:
for node in call_nodes(statement):
if node["call"] == check["call"] and all(
int(position) < len(node["args"]) and pattern_matches(pattern, node["args"][int(position)])
for position, pattern in check.get("args", {}).items()
):
count += 1
return count


def program_calls(check: dict, entry: Path, wright: str, state: dict) -> dict:
source = check.get("source", "oracle" if entry.suffix == ".opy" else "wright")
path = compiled_path(state, source)
if path is None:
return check_result(check, False, f"no compiled text from {source}", unavailable=source == "oracle" and not oracle_available())
max_rules = check.get("maxRules", 200)
cache = state.setdefault("ruleContent", {})
key = (source, max_rules)
if key not in cache:
cache[key] = rule_content(wright, path, max_rules)
response = cache[key]
if "error" in response:
# A Wright without the rules `content` field cannot grade this: unavailable, never a pass.
return check_result(check, False, f"rule content unavailable: {response['error'].get('message', response['error'])}", unavailable=True)
result = response["result"]
if not isinstance(result, dict) or any("content" not in rule for rule in result.get("rules", [])):
return check_result(check, False, "this wright does not serve rule content", unavailable=True)
if result["selection"]["withheld"]:
return check_result(check, False, f"incomplete: {result['selection']['withheld']} rule(s) withheld by maxRules")
count = count_calls(result["rules"], check)
return check_result(check, check.get("min", 1) <= count <= check.get("max", count), f"{count} matching '{check['call']}' call(s) in {source} output")


def check_result(check: dict, passed: bool, detail: str, **extra) -> dict:
return {"id": check["id"], "kind": check["kind"], "layer": check.get("layer", "agent"), "passed": passed, "detail": detail, **extra}

Expand Down Expand Up @@ -159,6 +240,8 @@ def run_check(check: dict, workspace: Path, entry: Path, wright: str, state: dic
return check_result(check, False, f"no compiled text from {source}", unavailable=source == "oracle" and not oracle_available())
found = matches(text, check)
return check_result(check, found if kind == "compiled-contains" else not found, f"{'matched' if found else 'no match'} in {source} output")
if kind == "program-calls":
return program_calls(check, entry, wright, state)
raise SystemExit(f"unknown check kind '{kind}'")


Expand Down
10 changes: 10 additions & 0 deletions benchmarks/agent/fixtures/program-calls/repel-one-short-stun.opy
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
playervar other

rule "Proximity repel":
@Event eachPlayer
@Condition distance(eventPlayer, eventPlayer.other) <= 6

eventPlayer.applyImpulse(directionTowards(eventPlayer.other, eventPlayer), 150, Relativity.TO_WORLD, Impulse.CANCEL_CONTRARY_MOTION_XYZ)
eventPlayer.other.applyImpulse(directionTowards(eventPlayer, eventPlayer.other), 150, Relativity.TO_WORLD, Impulse.CANCEL_CONTRARY_MOTION_XYZ)
eventPlayer.setStatusEffect(null, Status.STUNNED, 0.75)
eventPlayer.other.setStatusEffect(null, Status.STUNNED, 0.5)
22 changes: 22 additions & 0 deletions benchmarks/agent/fixtures/program-calls/repel.en-US.ow
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
variables {
player:
0: other
}

rule ("Proximity repel") {
event {
Ongoing - Each Player;
All;
All;
}
conditions {
Distance Between(Event Player, (Event Player).other) <= 6;
}
actions {
Apply Impulse(Event Player, Direction Towards((Event Player).other, Event Player), 150, To World, Cancel Contrary Motion XYZ);
Apply Impulse((Event Player).other, Direction Towards(Event Player, (Event Player).other), 150, To World, Cancel Contrary Motion XYZ);
Set Status(Event Player, Null, Stunned, 0.75);
Set Status((Event Player).other, Null, Stunned, 0.75);
}
}

10 changes: 10 additions & 0 deletions benchmarks/agent/fixtures/program-calls/repel.opy
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
playervar other

rule "Proximity repel":
@Event eachPlayer
@Condition distance(eventPlayer, eventPlayer.other) <= 6

eventPlayer.applyImpulse(directionTowards(eventPlayer.other, eventPlayer), 150, Relativity.TO_WORLD, Impulse.CANCEL_CONTRARY_MOTION_XYZ)
eventPlayer.other.applyImpulse(directionTowards(eventPlayer, eventPlayer.other), 150, Relativity.TO_WORLD, Impulse.CANCEL_CONTRARY_MOTION_XYZ)
eventPlayer.setStatusEffect(null, Status.STUNNED, 0.75)
eventPlayer.other.setStatusEffect(null, Status.STUNNED, 0.75)
22 changes: 22 additions & 0 deletions benchmarks/agent/fixtures/program-calls/repel.zh-CN.ow
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
变量 {
玩家:
0: other
}

规则 ("Proximity repel") {
事件 {
持续 - 每名玩家;
双方;
全部;
}
条件 {
相距距离(事件玩家, (事件玩家).other) <= 6;
}
动作 {
施加推力(事件玩家, 方向((事件玩家).other, 事件玩家), 150, 至地图, 取消相反运动XYZ);
施加推力((事件玩家).other, 方向(事件玩家, (事件玩家).other), 150, 至地图, 取消相反运动XYZ);
设置状态(事件玩家, 空, 击晕, 0.75);
设置状态((事件玩家).other, 空, 击晕, 0.75);
}
}

86 changes: 86 additions & 0 deletions benchmarks/agent/test_agent_bench.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,92 @@ def test_self_kill_exclusion_accepts_equivalent_forms_and_rejects_its_absence(se
self.assertTrue(bench_grade.matches(text, scored), text)
self.assertFalse(bench_grade.matches("Ongoing - Each Player;", scored))

def program_calls(self, fixture: str, checks: list[dict], source: str = "wright") -> list[bool]:
"""Run `program-calls` checks over one committed fixture, compiled by the named authority."""
entry = agent_bench.HERE / "fixtures/program-calls" / fixture
scratch = self.out / f"program-calls-{fixture}-{source}"
state = {"scratch": scratch, "authorities": bench_grade.authorities(WRIGHT, entry, scratch)}
return [bench_grade.run_check({"id": "c", "kind": "program-calls", "source": source, **check}, entry.parent, entry, WRIGHT, state)["passed"] for check in checks]

REPEL_CHECKS = (
{"call": "applyImpulse", "args": {"2": 150}, "min": 2},
{"call": "setStatusEffect", "args": {"2": {"enum": "Status", "member": "STUNNED"}, "3": 0.75}, "min": 2},
)

def test_program_calls_asserts_on_canonical_calls_in_every_surface_form(self):
for fixture in ("repel.opy", "repel.en-US.ow", "repel.zh-CN.ow"):
self.assertEqual(self.program_calls(fixture, list(self.REPEL_CHECKS)), [True, True], fixture)

def test_program_calls_rejects_a_missing_or_wrong_argument(self):
self.assertEqual(self.program_calls("repel-one-short-stun.opy", list(self.REPEL_CHECKS)), [True, False])
self.assertEqual(self.program_calls("repel.opy", [{"call": "applyImpulse", "args": {"2": 151}, "min": 1}, {"call": "applyImpulse", "args": {"2": 150}, "max": 1}]), [False, False])

def test_program_calls_alternatives_and_event_restriction(self):
any_of = {"call": "setStatusEffect", "args": {"3": {"any": [0.5, 0.75]}}, "min": 2}
self.assertEqual(self.program_calls("repel.opy", [any_of, {**any_of, "event": "global"}, {**any_of, "event": "eachPlayer"}]), [True, False, True])

@unittest.skipUnless(bench_grade.oracle_available(), "run `agent_bench.py setup-oracle`")
def test_program_calls_reads_the_upstream_oracle_output(self):
self.assertEqual(self.program_calls("repel.opy", list(self.REPEL_CHECKS), source="oracle"), [True, True])

def test_program_calls_fails_an_incomplete_read(self):
"""`withheld` rules mean the grader saw only part of the program: the check fails, it never passes on half."""
entry = agent_bench.HERE / "fixtures/program-calls/repel.opy"
scratch = self.out / "program-calls-withheld"
state = {"scratch": scratch, "authorities": bench_grade.authorities(WRIGHT, entry, scratch)}
partial = {"result": {"rules": [{"content": {"event": {"id": "eachPlayer"}, "conditions": [],
"actions": [{"call": "applyImpulse", "args": [None, None, 150]}]}}],
"selection": {"withheld": 3}}}
check = {"id": "c", "kind": "program-calls", "call": "applyImpulse", "args": {"2": 150}, "min": 2}
with patch.object(bench_grade, "rule_content", return_value=partial):
result = bench_grade.run_check(check, entry.parent, entry, WRIGHT, state)
self.assertFalse(result["passed"])
self.assertIn("withheld", result["detail"])

def test_program_calls_ignores_disabled_calls_and_rules(self):
"""A disabled statement or rule never runs, so its calls cannot satisfy a check."""
entry = agent_bench.HERE / "fixtures/program-calls/repel.opy"
scratch = self.out / "program-calls-disabled"
state = {"scratch": scratch, "authorities": bench_grade.authorities(WRIGHT, entry, scratch)}
impulse = {"call": "applyImpulse", "args": [None, None, 150]}
rules = [
{"content": {"event": {"id": "eachPlayer"}, "disabled": False,
"conditions": [{"value": impulse, "disabled": True}],
"actions": [impulse, {**impulse, "disabled": True}]}},
{"content": {"event": {"id": "eachPlayer"}, "disabled": True,
"conditions": [], "actions": [impulse, impulse]}},
]
response = {"result": {"rules": rules, "selection": {"withheld": 0}}}
check = {"id": "c", "kind": "program-calls", "call": "applyImpulse", "args": {"2": 150}, "min": 2}
with patch.object(bench_grade, "rule_content", return_value=response):
result = bench_grade.run_check(check, entry.parent, entry, WRIGHT, state)
self.assertFalse(result["passed"])
self.assertIn("1 matching", result["detail"])

def test_program_calls_is_unavailable_when_wright_cannot_serve_content(self):
"""A Wright without the rules `content` field cannot grade: `unavailable`, never a pass (ADR-0024)."""
entry = agent_bench.HERE / "fixtures/program-calls/repel.opy"
scratch = self.out / "program-calls-unavailable"
state = {"scratch": scratch, "authorities": bench_grade.authorities(WRIGHT, entry, scratch)}
check = {"id": "c", "kind": "program-calls", "call": "applyImpulse"}
# A pre-ADR-0024 wright ignores `content` and returns the bare rule list.
with patch.object(bench_grade, "rule_content", return_value={"result": [{"id": 0, "name": "r"}]}):
bare = bench_grade.run_check(check, entry.parent, entry, WRIGHT, state)
self.assertFalse(bare["passed"])
self.assertTrue(bare["unavailable"])
# A wright that refuses the request errors the same way.
with patch.object(bench_grade, "rule_content", return_value={"error": {"code": "invalid-request", "message": "unknown field"}}):
refused = bench_grade.run_check(check, entry.parent, entry, WRIGHT, state)
self.assertFalse(refused["passed"])
self.assertTrue(refused["unavailable"])

def test_pattern_matching_compares_numbers_by_value_and_never_confuses_booleans(self):
self.assertTrue(bench_grade.pattern_matches(0.75, 0.75))
self.assertTrue(bench_grade.pattern_matches(150, 150.0))
self.assertFalse(bench_grade.pattern_matches(1, True))
self.assertTrue(bench_grade.pattern_matches({"enum": "Status"}, {"enum": "Status", "member": "STUNNED"}))
self.assertFalse(bench_grade.pattern_matches({"enum": "Status", "member": "HACKED"}, {"enum": "Status", "member": "STUNNED"}))

def test_scenarios_are_solvable_and_not_vacuous(self):
self.assertTrue(agent_bench.validate(WRIGHT, self.out / "validate"))

Expand Down
Loading
Loading