Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
82fb7a2
simulator: strictly increasing non-negative PTS under jitter; factor …
vivekyarra Oct 26, 2026
abf6d18
tests: fuzz verdict invariants, adversarial planner, malformed and ed…
vivekyarra Oct 26, 2026
269971c
infra: physical resource names from aws.resource_prefix (neutral name…
vivekyarra Oct 26, 2026
62bfe3e
node: import recorded mains waveforms as 1 s references; add CN zone …
vivekyarra Oct 26, 2026
b8cbd49
docs: account decision, Bedrock status, dataset hand-off steps, verif…
vivekyarra Oct 26, 2026
7a44258
engine: register frames directly to the reference frame; run continui…
vivekyarra Oct 26, 2026
b042309
engine: video-level integration tests for VFR, axis, rotation, HDR, E…
vivekyarra Oct 26, 2026
49a2467
infra: build images with CodeBuild on Graviton when no local Docker e…
vivekyarra Oct 26, 2026
015ea6e
docs: progress with current milestone status
vivekyarra Oct 26, 2026
88b6473
engine: continuity detects phase jumps with two-sided line fits at ea…
vivekyarra Oct 26, 2026
1ebd964
api: serve demo inputs from the results bucket in AWS mode; seeding s…
vivekyarra Oct 26, 2026
b4f824d
infra: EC2 Graviton build backend for images (account has no CodeBuil…
vivekyarra Oct 26, 2026
6c34274
infra: fetch DNN models during the engine image build; README; licens…
vivekyarra Oct 26, 2026
0e18034
infra: include node ingest adapter and agent packages in the API and …
vivekyarra Oct 26, 2026
664e042
Merge remote-tracking branch 'origin/main' into feat/hardening-and-de…
vivekyarra Oct 26, 2026
6d36481
tests: infra synth tests follow the configured resource prefix; seed …
vivekyarra Oct 27, 2026
6d19116
style: import order in new tests
vivekyarra Oct 27, 2026
cb3d660
infra: Graviton workers sized to the account vCPU quota (c8g.xlarge, …
vivekyarra Oct 27, 2026
9cbde4f
docs: landing page for the project site
vivekyarra Oct 27, 2026
de0abb7
deploy: public HTTPS endpoint via API Gateway, web app in the API ima…
vivekyarra Oct 27, 2026
02ca65f
docs: decisions D-025 and D-026
vivekyarra Oct 27, 2026
f44c182
infra: 2-vCPU build instance to stay inside the account vCPU quota
vivekyarra Oct 27, 2026
f5a9ca1
agent: guard LLM steps that repeat failures or skip prerequisites; po…
vivekyarra Oct 27, 2026
b6a9439
agent: guard identical repeats and premature frequency/axis overrides…
vivekyarra Oct 27, 2026
c799f3f
infra: VPC link with its own security group (the default group has no…
vivekyarra Oct 27, 2026
95d9b22
fix: MCP tool server uses the DynamoDB GridLog in AWS mode; policy co…
vivekyarra Oct 27, 2026
eca43a4
agent: validate planner argument values and finish the job after unkn…
vivekyarra Oct 27, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,7 @@ capture-android/build/
capture-android/app/build/
capture-android/local.properties
infra/cdk.context.json
infra/image_uris.json
.worktrees/
work_deploy*.log
work_cdk_outputs.json
84 changes: 84 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# GRIDPRINT

GRIDPRINT checks whether a video is real by reading the power-grid frequency from the flicker of the lights in
the room. Mains-powered lights flicker at twice the grid frequency. A phone's rolling shutter reads the sensor one
row at a time, so the flicker shows up as faint horizontal bands. GRIDPRINT measures those bands with OpenCV 5,
rebuilds the grid-frequency track (the electric network frequency, ENF), and compares it with a recorded
reference log of the real grid. The result is one of three verdicts: `VERIFIED`, `CONTRADICTED` (only final after
a human reviewer approves it) or `INCONCLUSIVE`. A missing signal is never evidence that a video is fake. GRIDPRINT
is not a deepfake classifier and not cryptographic proof.

Entry for the OpenCV AI Competition 2026 (OpenCV 5 + AWS). The specification is in [SPEC.md](SPEC.md); current
status is in [docs/PROGRESS.md](docs/PROGRESS.md).

- Endpoint: see [docs/JUDGE_GUIDE.md](docs/JUDGE_GUIDE.md)
- Technical report: [docs/TECHNICAL_REPORT.md](docs/TECHNICAL_REPORT.md)
- Architecture: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) · diagrams in [docs/diagrams/](docs/diagrams/)
- Responsible use: [docs/RESPONSIBLE_USE.md](docs/RESPONSIBLE_USE.md)
- Video: (not recorded yet — human task H8)

## What is in the repository

| Part | Path |
|---|---|
| Engine (OpenCV 5 stages S1–S15) | `engine/gridprint_engine/` |
| Agent (planner, policies, trace) and MCP tool server | `agent/`, `mcp_server/` |
| API and job worker | `api/` |
| Verify web app | `web/` |
| Capture Android app | `capture-android/` |
| Grid-frequency Node (hardware, firmware, ingest) | `node/` |
| Camera calibration rig | `calibration_rig/` |
| AWS infrastructure (CDK v2) | `infra/` |
| Simulator and evaluation | `eval/` |
| COOL benchmark | `bench/` |

## Quick start (local)

Requirements: Python 3.11, [uv](https://docs.astral.sh/uv/), FFmpeg on `PATH`, Node.js 24 (for the web app).

```bash
make setup
```

```bash
make test
```

```bash
python -m eval.make_demo_inputs
```

```bash
make local
```

`make local` serves the API and the built web app at `http://127.0.0.1:8000` with local file storage and the
deterministic agent planner. The reviewer token for local use is written to `work/local_reviewer_token.txt`.
On Windows without GNU make, use `./make <target>` (Git Bash) or `make.cmd <target>`.

## Deploy to AWS

1. Complete human task H1 in [docs/HUMAN_TASKS.md](docs/HUMAN_TASKS.md) (CLI profile `gridprint`).
2. Build the arm64 images (no local Docker needed): `python infra/build_images.py`.
3. Deploy all stacks: `make deploy`. Remove everything: `make destroy`.

## Evaluate and benchmark

```bash
make eval
```

```bash
make bench
```

Synthetic results are labeled SYNTHETIC and are never presented as real-world accuracy. Every reported number
lives under `results/` with a source tag (SPEC 14.3).

## Final check

```bash
make final-check
```

It prints one line per item of the final checklist (SPEC 17.3) and `ALL CHECKS PASSED` only when all hold.
37 changes: 37 additions & 0 deletions agent/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,36 @@ async def call(self, tool: str, args: dict[str, Any]) -> dict[str, Any]:
return json.loads(text) if text else {}


_PREREQ_FREE = {"probe_video", "compute_verdict", "request_user_input", "request_human_review", "render_report"}


def _probe_ok(history: list[Step]) -> bool:
return any(h.tool == "probe_video" and h.error is None for h in history)


def _needs_rule(a: Action, history: list[Step]) -> bool:
if a.tool not in {"render_report", "compute_verdict"} and any(h.tool == a.tool and h.args == a.args
for h in history):
return True # identical call already made (failed or not): no new information
if a.tool == "extract_flicker" and (a.args.get("nominal_hz") or a.args.get("axis")) and not any(
h.tool == "extract_flicker" and h.result for h in history):
return True # B1 allows switching frequency or axis only after a weak first extraction
asked = {h.args.get("kind") for h in history if h.tool == "request_user_input"}
if a.tool == "request_user_input" and a.args.get("kind") in asked:
return True
return a.tool not in _PREREQ_FREE and not _probe_ok(history)


def _why(a: Action, history: list[Step]) -> str:
if any(h.tool == a.tool and h.args == a.args for h in history):
return "identical call already made"
if a.tool == "extract_flicker" and not any(h.tool == "extract_flicker" and h.result for h in history):
return "frequency or axis override before the first extraction"
if a.tool == "request_user_input":
return "same request already made"
return "video not probed yet"


async def run_job(job_id: str, planner: Planner, caller: ToolCaller, max_iterations: int,
max_tool_calls: int, trace_path: Path | None = None) -> tuple[Trace, list[Step]]:
trace = Trace(job_id)
Expand All @@ -56,6 +86,13 @@ async def run_job(job_id: str, planner: Planner, caller: ToolCaller, max_iterati
except Exception as e: # planner failure (e.g. Bedrock unavailable): fall back for this step
proposed = FallbackPlanner().next_action(history)
proposed.reason = f"planner error ({type(e).__name__}); fallback rule: {proposed.reason}"
# Guard the LLM planner: a call that repeats a failed call, or an analysis call before the video was
# probed successfully, is replaced by the deterministic rule for this step (recorded in the trace).
if planner.kind != "fallback" and proposed.tool is not None and _needs_rule(proposed, history):
rule = FallbackPlanner().next_action(history)
rule.reason = (f"guard: planner proposed {proposed.tool} {proposed.args} ({_why(proposed, history)}); "
f"fallback rule: {rule.reason}")
proposed = rule
action, note = policies.enforce(proposed, st)
if action.tool is None:
if proposed.tool is not None or note:
Expand Down
15 changes: 13 additions & 2 deletions agent/planner_bedrock.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,18 @@ def next_action(self, history: list[Step]) -> Action:
return Action(None, {}, reason or "no tool chosen")


def make_client(profile: str | None, region: str) -> Any:
def make_client(profile: str | None, region: str, role_arn: str | None = None,
external_id: str | None = None) -> Any:
"""bedrock-runtime client. With `role_arn`, the model is called through a role in the model account
(cross-account, D-026); the role allows only InvokeModel on the pinned model."""
import boto3

return boto3.Session(profile_name=profile, region_name=region).client("bedrock-runtime")
session = boto3.Session(profile_name=profile, region_name=region)
if role_arn:
kw = {"RoleArn": role_arn, "RoleSessionName": "planner"}
if external_id:
kw["ExternalId"] = external_id
c = session.client("sts").assume_role(**kw)["Credentials"]
session = boto3.Session(aws_access_key_id=c["AccessKeyId"], aws_secret_access_key=c["SecretAccessKey"],
aws_session_token=c["SessionToken"], region_name=region)
return session.client("bedrock-runtime")
52 changes: 44 additions & 8 deletions agent/policies.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,40 @@
}


def _zones() -> set[str]:
try:
from pathlib import Path

import yaml

p = Path(__file__).resolve().parents[1] / "config" / "zones.yaml"
return {z["id"] for z in yaml.safe_load(p.read_text())["zones"]}
except Exception: # config missing: accept no zone argument
return set()


# allowed values per argument; a value outside the set is dropped (the engine default applies)
ALLOWED_VALUES: dict[str, Any] = {
"method": {"auto", "sidecar", "profile", "blind"}, "strictness": {"normal", "strict"},
"strategy": {"all", "top", "coherent", "bright"}, "axis": {"rows+", "rows-", "cols+", "cols-"},
"nominal_hz": {50, 60}, "kind": {"original_file", "camera_model", "claimed_time", "longer_clip"},
"final": {True, False}, "zone": _zones(),
}


def _valid(k: str, v: Any) -> bool:
if k in ALLOWED_VALUES:
try:
return v in ALLOWED_VALUES[k]
except TypeError:
return False
if k == "patch_ids":
return isinstance(v, list) and all(isinstance(i, int) for i in v)
if k == "frame_subset":
return v == "low_motion" or (isinstance(v, list) and all(isinstance(i, int) for i in v))
return isinstance(v, str | int | float) or v is None


@dataclass
class Action:
tool: str | None # None = planner wants to stop
Expand Down Expand Up @@ -53,10 +87,13 @@ def enforce(a: Action, st: PolicyState) -> tuple[Action, str | None]:
"policy: report must be rendered"), "report required"
return a, None
if a.tool not in ALL_TOOLS:
return Action("compute_verdict" if not st.verdict_done else None, {},
"policy: unknown tool rejected"), f"unknown tool {a.tool}"
if st.verdict_done:
nxt, _ = enforce(Action(None, {}, ""), st)
nxt.reason = f"policy: unknown tool {a.tool} rejected; " + (nxt.reason or "job complete")
return nxt, f"unknown tool {a.tool}"
return Action("compute_verdict", {}, "policy: unknown tool rejected"), f"unknown tool {a.tool}"
note = None
args = {k: v for k, v in a.args.items() if k in ALLOWED_ARGS[a.tool]}
args = {k: v for k, v in a.args.items() if k in ALLOWED_ARGS[a.tool] and _valid(k, v)}
if args != {k: v for k, v in a.args.items() if k != "job_id"}:
note = "policy: disallowed arguments removed"
a = Action(a.tool, args, a.reason)
Expand All @@ -66,11 +103,10 @@ def enforce(a: Action, st: PolicyState) -> tuple[Action, str | None]:
if a.tool == "extract_flicker" and st.iterations >= st.max_iterations:
st.budget_exhausted = True
return Action("compute_verdict", {}, "policy: iteration budget exhausted (B5)"), "iteration budget"
if st.verdict_done and a.tool in ANALYSIS_TOOLS:
return Action(None, {}, "policy: compute_verdict must be the last analysis call"), \
f"{a.tool} after verdict blocked"
if st.verdict_done and a.tool == "compute_verdict":
return Action(None, {}, "policy: verdict already computed"), "repeat verdict blocked"
if st.verdict_done and a.tool in ANALYSIS_TOOLS | {"compute_verdict"}:
nxt, _ = enforce(Action(None, {}, ""), st) # finish the job: review if needed, then the report
nxt.reason = f"policy: {a.tool} after the verdict is not allowed; " + (nxt.reason or "job complete")
return nxt, f"{a.tool} after verdict blocked"
if a.tool == "render_report":
if not st.verdict_done:
return Action("compute_verdict", {}, "policy: verdict before report"), "report before verdict"
Expand Down
67 changes: 66 additions & 1 deletion agent/tests/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ def test_policy_blocks_final_report_for_contradicted_and_analysis_after_verdict(
a, note = policies.enforce(Action("render_report", {"final": True}, "x"), st)
assert a.tool == "render_report" and a.args["final"] is False and note
a, _ = policies.enforce(Action("extract_flicker", {}, "x"), st)
assert a.tool is None
assert a.tool == "render_report" and a.args["final"] is False # no analysis after the verdict


def test_policy_strips_threshold_arguments():
Expand Down Expand Up @@ -198,3 +198,68 @@ def test_bedrock_planner_is_policed():
def test_tool_config_lists_all_tools():
names = {t["toolSpec"]["name"] for t in tool_config()["tools"]}
assert policies.ALL_TOOLS <= names


class FailingProbeTools(ScriptedTools):
"""probe_video fails once, then succeeds."""

def __init__(self) -> None:
super().__init__()
self.probe_failed = False

async def call(self, tool: str, args: dict[str, Any]) -> dict[str, Any]:
if tool == "probe_video" and not self.probe_failed:
self.calls.append((tool, args))
self.probe_failed = True
raise RuntimeError("Unable to allocate memory")
return await super().call(tool, args)


def test_llm_guard_replaces_repeated_failures_and_out_of_order_calls():
# an LLM that ignores errors: always asks for motion analysis
client = FakeBedrock([{"tool": "analyze_motion", "input": {"strictness": "normal"}}])
t = FailingProbeTools()
trace, _ = run(t, BedrockPlanner(client, "test-model"))
names = tools_called(t)
assert names[0] == "probe_video" and names[1] == "compute_verdict" # guard: unreadable input ends the job
assert any("guard:" in s.decision_reason for s in trace.steps)
assert names.count("analyze_motion") <= 2 # the repeated call is not retried endlessly
assert names[-1] == "render_report" # the job always ends with a report


def test_llm_guard_blocks_premature_overrides_and_identical_repeats():
script = [{"tool": "probe_video"}, {"tool": "estimate_readout", "input": {"method": "auto"}},
{"tool": "analyze_motion", "input": {"strictness": "normal"}},
{"tool": "propose_patches", "input": {"strategy": "top"}},
{"tool": "extract_flicker", "input": {"nominal_hz": 60}},
{"tool": "propose_patches", "input": {"strategy": "top"}}]
client = FakeBedrock(script)
t = ScriptedTools()
trace, _ = run(t, BedrockPlanner(client, "test-model"))
ex = [a for n, a in t.calls if n == "extract_flicker"]
assert ex and "nominal_hz" not in ex[0] # the first extraction used the zone default
assert sum(1 for n, a in t.calls if n == "propose_patches" and a.get("strategy") == "top") <= 1
assert any("frequency or axis override" in s.decision_reason for s in trace.steps)
assert t.calls[-1][0] == "render_report"


def test_policy_after_verdict_finishes_with_report():
st = PolicyState(max_iterations=6, max_tool_calls=30, verdict_done=True, verdict="INCONCLUSIVE")
a, note = policies.enforce(Action("compute_verdict", {}, "again"), st)
assert a.tool == "render_report" and a.args["final"] is True and note
st2 = PolicyState(max_iterations=6, max_tool_calls=30, verdict_done=True, verdict="CONTRADICTED")
a, _ = policies.enforce(Action("match_reference", {}, "late"), st2)
assert a.tool == "request_human_review"


def test_policy_drops_invalid_argument_values():
st = PolicyState(max_iterations=6, max_tool_calls=30)
a, note = policies.enforce(Action("match_reference", {"track_id": "t1", "zone": "global"}, "x"), st)
assert a.args == {"track_id": "t1"} and note
a, _ = policies.enforce(Action("extract_flicker", {"nominal_hz": 55, "axis": "diagonal"}, "x"), st)
assert a.args == {}
a, _ = policies.enforce(Action("match_reference", {"zone": "SIM50"}, "x"), st)
assert a.args == {"zone": "SIM50"}
st.verdict_done, st.verdict = True, "INCONCLUSIVE"
a, _ = policies.enforce(Action("render", {}, "x"), st)
assert a.tool == "render_report"
33 changes: 29 additions & 4 deletions api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,20 +195,40 @@ def decide(review_id: str, body: DecisionBody, who: str = Depends(require_review
ctx.store.put("Jobs", rv["job_id"], job)
return {"review_id": review_id, "verdict": v.value, "lifecycle_state": st.value}

def demo_available(c: dict[str, Any]) -> bool:
if ctx.mode == "aws": # demo inputs are seeded to the results bucket by scripts/seed_aws.py
return bool(c.get("seeded", True))
return (REPO / c["video"]).exists()

def demo_input(c: dict[str, Any], field: str, job_id: str) -> Path | None:
rel = c.get(field)
if not rel:
return None
if ctx.mode == "aws":
dst = ctx.workdir / job_id / Path(rel).name
return ctx.storage.get_file(f"demo/{Path(rel).name}", dst)
return REPO / rel

@app.get("/v1/demo-cases")
def demo_cases() -> list[dict[str, Any]]:
return [{k: c[k] for k in ("id", "title", "description", "expected_verdict")} for c in load_demo_cases()
if (REPO / c["video"]).exists()]
if demo_available(c)]

@app.post("/v1/demo-cases/{case_id}/run")
def run_demo(case_id: str) -> dict[str, Any]:
case = next((c for c in load_demo_cases() if c["id"] == case_id), None)
if case is None or not (REPO / case["video"]).exists():
if case is None or not demo_available(case):
raise HTTPException(404, "unknown demo case")
job_id = uuid.uuid4().hex[:12]
key = f"uploads/{job_id}/input.mp4"
ctx.storage.put_file(key, REPO / case["video"])
sidecar = json.loads((REPO / case["sidecar"]).read_text()) if case.get("sidecar") else None
try:
video = demo_input(case, "video", job_id)
side_p = demo_input(case, "sidecar", job_id)
except Exception as e:
raise HTTPException(404, "demo input not available") from e
assert video is not None
ctx.storage.put_file(key, video)
sidecar = json.loads(side_p.read_text()) if side_p else None
ctx.store.put("Jobs", job_id, {"job_id": job_id, "status": "queued", "upload_key": key,
"claim": case.get("claim") or {}, "sidecar": sidecar, "demo_case": case_id,
"created": datetime.now(tz=UTC).isoformat()})
Expand All @@ -234,6 +254,11 @@ def files(path: str) -> FileResponse:
raise HTTPException(404, "not found")
return FileResponse(p)

dist = REPO / "web" / "dist"
if dist.exists(): # the Verify web app, served from the same origin as the API
from fastapi.staticfiles import StaticFiles

app.mount("/", StaticFiles(directory=dist, html=True), name="web")
return app


Expand Down
6 changes: 1 addition & 5 deletions api/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
from pathlib import Path

import uvicorn
from fastapi.staticfiles import StaticFiles

REPO = Path(__file__).resolve().parents[1]

Expand All @@ -24,10 +23,7 @@ def main() -> None:
os.environ["GRIDPRINT_REVIEWER_TOKEN"] = tok_file.read_text().strip()
from api.app import create_app

app = create_app()
dist = REPO / "web" / "dist"
if dist.exists():
app.mount("/", StaticFiles(directory=dist, html=True), name="web")
app = create_app() # also serves web/dist when it has been built
print(f"GRIDPRINT local: http://127.0.0.1:8000 (reviewer token in {tok_file})")
uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("PORT", "8000")))

Expand Down
Loading