From 38433dc85a1caa5256363d71e662aff3d4a5cd8e Mon Sep 17 00:00:00 2001 From: fengweit <2412561+fengweit@users.noreply.github.com> Date: Thu, 16 Jul 2026 06:53:58 +0800 Subject: [PATCH 01/17] feat(benchmark): add replayable video relation evals --- .gitignore | 1 + dimos/benchmark/spatiotemporal/README.md | 479 +++++++++++++++++ dimos/benchmark/spatiotemporal/bundles.py | 280 ++++++++++ dimos/benchmark/spatiotemporal/demo.py | 462 ++++++++++++++++ dimos/benchmark/spatiotemporal/generation.py | 164 ++++++ dimos/benchmark/spatiotemporal/intervals.py | 138 +++++ dimos/benchmark/spatiotemporal/models.py | 395 ++++++++++++++ .../spatiotemporal/observation_io.py | 105 ++++ dimos/benchmark/spatiotemporal/ports.py | 155 ++++++ dimos/benchmark/spatiotemporal/relations.py | 112 ++++ dimos/benchmark/spatiotemporal/replay.py | 200 +++++++ dimos/benchmark/spatiotemporal/runner.py | 123 +++++ dimos/benchmark/spatiotemporal/scoring.py | 91 ++++ .../temporal_memory_answerer.py | 66 +++ .../benchmark/spatiotemporal/test_bundles.py | 494 ++++++++++++++++++ dimos/benchmark/spatiotemporal/test_demo.py | 218 ++++++++ .../spatiotemporal/test_generation.py | 213 ++++++++ .../spatiotemporal/test_intervals.py | 245 +++++++++ dimos/benchmark/spatiotemporal/test_models.py | 326 ++++++++++++ .../spatiotemporal/test_observation_io.py | 135 +++++ dimos/benchmark/spatiotemporal/test_ports.py | 147 ++++++ .../spatiotemporal/test_relations.py | 272 ++++++++++ dimos/benchmark/spatiotemporal/test_replay.py | 168 ++++++ dimos/benchmark/spatiotemporal/test_runner.py | 128 +++++ .../benchmark/spatiotemporal/test_scoring.py | 101 ++++ .../test_temporal_memory_answerer.py | 77 +++ .../spatiotemporal/test_utilities.py | 39 ++ .../spatiotemporal/test_video_adapter.py | 182 +++++++ .../spatiotemporal/test_yoloe_adapter.py | 219 ++++++++ dimos/benchmark/spatiotemporal/utilities.py | 48 ++ .../benchmark/spatiotemporal/video_adapter.py | 88 ++++ .../benchmark/spatiotemporal/yoloe_adapter.py | 148 ++++++ 32 files changed, 6019 insertions(+) create mode 100644 dimos/benchmark/spatiotemporal/README.md create mode 100644 dimos/benchmark/spatiotemporal/bundles.py create mode 100644 dimos/benchmark/spatiotemporal/demo.py create mode 100644 dimos/benchmark/spatiotemporal/generation.py create mode 100644 dimos/benchmark/spatiotemporal/intervals.py create mode 100644 dimos/benchmark/spatiotemporal/models.py create mode 100644 dimos/benchmark/spatiotemporal/observation_io.py create mode 100644 dimos/benchmark/spatiotemporal/ports.py create mode 100644 dimos/benchmark/spatiotemporal/relations.py create mode 100644 dimos/benchmark/spatiotemporal/replay.py create mode 100644 dimos/benchmark/spatiotemporal/runner.py create mode 100644 dimos/benchmark/spatiotemporal/scoring.py create mode 100644 dimos/benchmark/spatiotemporal/temporal_memory_answerer.py create mode 100644 dimos/benchmark/spatiotemporal/test_bundles.py create mode 100644 dimos/benchmark/spatiotemporal/test_demo.py create mode 100644 dimos/benchmark/spatiotemporal/test_generation.py create mode 100644 dimos/benchmark/spatiotemporal/test_intervals.py create mode 100644 dimos/benchmark/spatiotemporal/test_models.py create mode 100644 dimos/benchmark/spatiotemporal/test_observation_io.py create mode 100644 dimos/benchmark/spatiotemporal/test_ports.py create mode 100644 dimos/benchmark/spatiotemporal/test_relations.py create mode 100644 dimos/benchmark/spatiotemporal/test_replay.py create mode 100644 dimos/benchmark/spatiotemporal/test_runner.py create mode 100644 dimos/benchmark/spatiotemporal/test_scoring.py create mode 100644 dimos/benchmark/spatiotemporal/test_temporal_memory_answerer.py create mode 100644 dimos/benchmark/spatiotemporal/test_utilities.py create mode 100644 dimos/benchmark/spatiotemporal/test_video_adapter.py create mode 100644 dimos/benchmark/spatiotemporal/test_yoloe_adapter.py create mode 100644 dimos/benchmark/spatiotemporal/utilities.py create mode 100644 dimos/benchmark/spatiotemporal/video_adapter.py create mode 100644 dimos/benchmark/spatiotemporal/yoloe_adapter.py diff --git a/.gitignore b/.gitignore index dd4ad6079f..17f3559802 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ package-lock.json # Ignore build artifacts dist/ build/ +/.artifacts/ # Rust build artifacts **/target/ diff --git a/dimos/benchmark/spatiotemporal/README.md b/dimos/benchmark/spatiotemporal/README.md new file mode 100644 index 0000000000..893561f650 --- /dev/null +++ b/dimos/benchmark/spatiotemporal/README.md @@ -0,0 +1,479 @@ +# Spatiotemporal Video-QA Benchmark + +This package provides a deterministic, privacy-separated benchmark for asking spatial and temporal questions about tracked objects in video. It includes strict data contracts, real OpenCV decoding, real YOLO-E detection/tracking, deterministic relation and question generation, public/private bundle separation, replay, exact scoring, a TemporalMemory candidate adapter, and a verified end-to-end demo. + +The benchmark is designed so a candidate sees only the source video and answer-free public questions. Teacher observations, accepted relations, intervals, oracle answers, and evidence remain private until evaluation. + +## What is real, and what is scripted + +The end-to-end demo uses: + +- a real LFS-backed video; +- real OpenCV decoding and frame sampling; +- real YOLO-E 11s weights and inference; +- real persistent tracker IDs; +- real relation, interval, question, bundle, replay, and scoring code; and +- the real TemporalMemory accumulator, state, entity graph, query lifecycle, readiness, and cleanup paths. + +The demo's TemporalMemory VLM fixture is intentionally timestamp-scripted and its final yes/no baseline is a stable hash of the public question. It does **not** inspect image pixels and is **not** presented as a visual QA model. Its purpose is to verify TemporalMemory plumbing and the public/private boundary without requiring cloud credentials. The YOLO-E teacher path is the visually grounded part of the reference demo. + +## Architecture + +```text +LFS video + | + v +OpenCVVideoSampler -----> YoloeObservationDetector -----> canonical ObjectObservation JSONL + | + v + spatial RelationFact records + | + v + strict RelationInterval records + | + +----------------------+----------------------+ + | | + v v + public Question records private OracleAnswer records + | | + v v + public bundle manifest <---- cryptographic bind ---- oracle manifest + | + v + CandidateAnswerer(video + public questions only) + | + v + Prediction records + | + +---------------- evaluator only ----------------+ + | + v + exact EvaluationReport +``` + +Teacher and candidate execution are deliberately separate: + +- `_run_temporal_memory_candidate(...)` accepts only the video, public `Question` records, an ephemeral candidate directory, and duration. +- `_score_candidate(...)` runs in the evaluator boundary and owns `OracleAnswer` records. +- The candidate function never receives an `EvaluationBundle`, observations, facts, intervals, evidence, or oracle answers. + +## Package map + +| File | Purpose | +|---|---| +| `models.py` | Strict frozen Pydantic v2 records and closed predicate/status enums | +| `utilities.py` | Canonical JSON, schema version, and stable SHA-256 identifiers | +| `relations.py` | Image-plane `left-of`, `right-of`, `above`, and `below` derivation | +| `intervals.py` | Coalescing accepted relation facts into bounded intervals | +| `generation.py` | Spatial and strict temporal question generation | +| `bundles.py` | Canonical public/private artifacts, manifests, loading, and validation | +| `observation_io.py` | Strict canonical observation JSONL read/write | +| `replay.py` | Deterministic observation-to-bundle replay | +| `ports.py` | Frozen detector, generator, replay, and candidate seams | +| `video_adapter.py` | Deterministic OpenCV video sampling | +| `yoloe_adapter.py` | YOLO-E normalization and identity continuity statistics | +| `temporal_memory_answerer.py` | Public-only TemporalMemory candidate lifecycle adapter | +| `runner.py` | Candidate answer parsing and aggregate report construction | +| `scoring.py` | Exact Boolean scoring and evidence-linked diagnostics | +| `demo.py` | One-command real video/YOLO-E/TemporalMemory plumbing demo | +| `test_*.py` | Contract, privacy, determinism, safety, and integration tests | + +## Frozen contracts and invariants + +### Record strictness + +Every benchmark record derives from `StrictFrozenModel`: + +- Pydantic v2 strict mode; +- `frozen=True`; +- `extra="forbid"`; +- no implicit type coercion; +- finite numeric values; +- normalized, non-empty strings; and +- deterministic validation of stable identifiers. + +Normalized bounding boxes use `[0, 1]` coordinates and must have strictly increasing minima/maxima. + +### Supported predicates + +Spatial predicates are closed to: + +- `left-of` +- `right-of` +- `above` +- `below` + +Temporal predicates are closed to: + +- `before` +- `after` + +Temporal comparisons are strict. Touching or overlapping intervals are neither strictly before nor strictly after. + +### Stable identifiers + +Question, relation, interval, and bundle IDs are SHA-256-derived stable IDs. Their preimages include the relevant schema version and semantic fields. Question IDs include the episode identity so equivalent questions from different episodes cannot collide. + +Do not change stable-ID preimages, enum values, or schema version casually. Such a change is a benchmark schema migration and requires explicit compatibility analysis and regenerated fixtures. + +### Relation and interval rules + +- Relation subjects and objects must differ. +- Fact evidence cites exactly the fact's sampled frame. +- Evidence IDs are ordered and unique. +- An interval represents one stable relation ID. +- Frame and timestamp endpoints must agree. +- A single-frame interval must also have identical timestamps; a multi-frame interval must span increasing timestamps. +- Facts and intervals are private teacher artifacts. + +### Question and oracle rules + +- Public questions contain no expected answer or teacher evidence. +- Spatial public questions refer to object IDs and one spatial predicate. +- Temporal public questions refer to stable relation IDs, not private interval payloads. +- Oracle answers carry the expected Boolean and private evidence frame/interval IDs. +- Candidate predictions accept only exact booleans or the exact lowercase strings `yes` and `no`. +- Missing, invalid, correct, and incorrect are distinct scoring statuses. + +### Bundle privacy + +The public bundle contains only release metadata and answer-free public questions. The private oracle bundle contains observations, relation facts, intervals, answers, and evidence. Both manifests share one `bundle_id`; the oracle manifest is cryptographically bound to the exact public manifest SHA-256. + +Logical replay hashing is root-independent: writing the same logical bundle under different directories must produce the same manifests and logical digest. + +## Prerequisites + +Run from the repository root with the project environment available through `uv`. + +### 1. Git LFS + +Install Git LFS, initialize it for this repository, and materialize the video and YOLO-E archive: + +```bash +git lfs version +git lfs install --local +git lfs pull --include='assets/simple_demo.mp4,data/.lfs/models_yoloe.tar.gz' +``` + +Verify that the files are binaries rather than ~130-byte pointer files: + +```bash +python3 - <<'PY' +from pathlib import Path +for name in ("assets/simple_demo.mp4", "data/.lfs/models_yoloe.tar.gz"): + path = Path(name) + print(name, path.stat().st_size, "bytes") + assert path.stat().st_size > 1024, f"{name} is still an LFS pointer" +PY +``` + +Reference sizes are approximately 51 MB for `simple_demo.mp4` and 185 MB for `models_yoloe.tar.gz`. + +### 2. Extract YOLO-E weights + +```bash +uv run python -c "from dimos.utils.data import get_data; print(get_data('models_yoloe'))" +``` + +The reference prompt model is: + +```text +data/models_yoloe/yoloe-11s-seg.pt +``` + +### 3. Install Ultralytics CLIP + +YOLO-E text prompting requires Ultralytics' CLIP fork: + +```bash +uv pip install 'git+https://github.com/ultralytics/CLIP.git' +``` + +The first prompt-mode run may also download `mobileclip_blt.ts` into the Ultralytics user cache. + +## One-command demo + +```bash +uv run python -m dimos.benchmark.spatiotemporal.demo \ + --source-video assets/simple_demo.mp4 \ + --output-root .artifacts/spatiotemporal-video-qa \ + --duration-s 25 \ + --frame-stride 150 +``` + +Constraints: + +- duration must be between 15 and 30 seconds; +- frame stride must be positive; +- the source must be a regular non-symlink file; +- the output path and fixed artifact children must not contain symlinks; +- source and target must not alias by path or hard link; and +- encoded output is reopened and every expected frame is decoded before inference begins. + +The reference command uses CPU explicitly for reproducibility. + +## Output layout + +```text +.artifacts/spatiotemporal-video-qa/ +├── office_robot_25s.mp4 +├── observations.jsonl +├── summary.json +├── bundle-a/ +│ ├── public/ +│ └── oracle/ +├── bundle-b/ +│ ├── public/ +│ └── oracle/ +``` + +`bundle-a` and `bundle-b` are independent writes of the same logical teacher data. Their public and oracle manifests must compare equal. +TemporalMemory state is written under an ephemeral directory outside this teacher output and removed after candidate execution. + +## What the demo verifies + +The command fails unless all of these gates hold: + +1. the constrained output video contains exactly the expected number of decodable frames; +2. two fresh YOLO-E detector/tracker instances produce identical observations and statistics; +3. canonical observations can be written and strictly re-read; +4. observations produce accepted spatial facts and intervals; +5. the teacher generates at least one public question; +6. two independent bundle roots have equal public and oracle manifests; +7. replay logical SHA-256 values match across roots; +8. TemporalMemory ingests public frames and reports ready; +9. all public questions receive a valid candidate response; +10. scoring joins every public question to exactly one private oracle answer; and +11. TemporalMemory and detector resources are closed. + +## Reference result + +The verified macOS CPU run produced: + +- 25 seconds / 750 decoded frames; +- 5 sampled YOLO-E frames per detector run; +- 2 independent detector/tracker runs with identical results; +- 8 observations; +- 4 native object identities; +- 0 fallback identities; +- 6 relation facts; +- 6 relation intervals; +- 54 public questions: 6 spatial and 48 temporal; +- 54 private answers; +- logical bundle SHA-256 `ba4be04df7ed376925551bf998b6dce3fa08678665644ec0337c6eda6f9d62a9`; +- TemporalMemory readiness after 25 public frames; +- 5 analyzed TemporalMemory windows; +- 3 TemporalMemory state entities; +- 54 valid timestamp-scripted predictions; +- 26 correct, 28 incorrect, 0 missing, and 0 invalid; and +- byte-identical summaries across two complete command invocations. + +Reference full-summary SHA-256: + +```text +610c4bba46705d2ba61240e904faed732199c2fe75d8bb8be86d36a7463bf491 +``` + +## Verification commands + +### Contract and integration tests + +```bash +uv run pytest dimos/benchmark/spatiotemporal -q +``` + +Expected result for this curated import: 105 tests pass. This count grows with the focused TDD changes below. + +### Formatting, lint, and typing + +```bash +uv run ruff format --check dimos/benchmark/spatiotemporal +uv run ruff check dimos/benchmark/spatiotemporal +uv run mypy +``` + +### Inspect the machine-readable result + +```bash +python3 - <<'PY' +import json +from pathlib import Path +s = json.loads(Path('.artifacts/spatiotemporal-video-qa/summary.json').read_text()) +assert s['video']['duration_s'] == 25 +assert s['video']['frames'] == 750 +assert s['teacher']['detector_repeat_equal'] is True +assert len(s['teacher']['unique_object_ids']) >= 3 +assert s['teacher']['relation_facts'] > 0 +assert s['teacher']['relation_intervals'] > 0 +assert s['teacher']['spatial_questions'] > 0 +assert s['teacher']['temporal_questions'] > 0 +assert s['candidate']['candidate_used_oracle'] is False +assert s['candidate']['readiness']['ready'] is True +assert s['candidate']['readiness']['ingested_frame_count'] == 25 +assert s['candidate']['questions_answered'] == s['teacher']['questions'] +assert s['candidate']['status_counts']['missing'] == 0 +assert s['candidate']['status_counts']['invalid'] == 0 +print('SUMMARY_GATES=PASS') +PY +``` + +### Verify complete-run repeatability + +```bash +cp .artifacts/spatiotemporal-video-qa/summary.json /tmp/stqa-summary-first.json +uv run python -m dimos.benchmark.spatiotemporal.demo \ + --source-video assets/simple_demo.mp4 \ + --output-root .artifacts/spatiotemporal-video-qa \ + --duration-s 25 \ + --frame-stride 150 +cmp /tmp/stqa-summary-first.json .artifacts/spatiotemporal-video-qa/summary.json +shasum -a 256 .artifacts/spatiotemporal-video-qa/summary.json +``` + +`cmp` must produce no output and exit zero. + +## Safety and privacy checks + +The test suite covers: + +- candidate function signature excludes private bundles; +- candidate answers are stable when irrelevant context timestamps change; +- invalid duration and stride fail before output side effects; +- symlinked output roots and artifact children are rejected; +- source/target path aliases are rejected; +- source/target hard-link aliases are rejected without modifying the source; +- silent `VideoWriter.write()` failure is detected by full decode verification; +- duplicate fallback detector labels are rejected; +- observations reject duplicate JSON keys and noncanonical JSONL; +- public manifests cannot reference private artifacts; +- oracle manifests bind to one exact public manifest; and +- questions, answers, and evidence IDs must join exactly. + +Do not weaken these checks to make a custom video pass. Improve the input, prompts, detector settings, or explicitly version the benchmark contract. + +## Using a different video + +A useful custom video should provide: + +- a valid finite FPS; +- 15-30 seconds of decodable frames; +- at least three reliably tracked native identities; +- spatially separated object pairs; and +- at least one change that creates non-overlapping relation intervals for temporal questions. + +The default prompt set is defined in `demo.py`. Multiple untracked detections with the same fallback label are intentionally rejected because label-only IDs would be ambiguous. Prefer native tracker IDs or a prompt set that yields unique fallback labels. + +The demo episode ID is currently fixed for the reference clip. If generalizing the CLI for arbitrary datasets, add an explicit episode argument and preserve the stable-ID contract. + +## Development and integration guidance + +This benchmark was developed as isolated lanes and should continue to use the same discipline for contract-sensitive changes: + +- one branch and worktree per independent lane; +- the main worktree is reserved for controlled integration; +- shared contracts are frozen before parallel implementation; +- lane ownership guards prevent edits outside assigned paths; +- progress is tracked in machine-readable lane ledgers; +- workers commit and push small independently testable units; +- only integration updates the global board, resolves conflicts, or runs private/real gates; +- do not merge a lane until its focused tests, full package tests, Ruff, mypy, and remote-head verification pass; +- keep candidate-facing code physically and structurally separated from oracle data; and +- never use oracle answers to tune or generate candidate predictions during a scored run. + +The completed lane and gate record is in: + +```text +.hermes/plans/spatiotemporal-video-qa-parallel/board.md +``` + +## Independent verification agent runbook + +A verifier must operate read-only and treat this README as the procedure under test. + +### Agent rules + +1. Start from `/Users/tian/dimos` on `feat/spatiotemporal-video-qa`. +2. Do not edit, format, commit, reset, clean, or push. +3. Do not read `.env`, credentials, or private user files. +4. Confirm the Git worktree is clean before verification. +5. Follow the prerequisites and one-command demo in this README. +6. Run the package tests, Ruff checks, and mypy. +7. Run the summary assertions and repeatability check. +8. Inspect code/tests for each advertised privacy and safety invariant. +9. Distinguish real YOLO-E inference from the timestamp-scripted TemporalMemory VLM fixture. +10. Return `PASS` only if every applicable gate passes; otherwise return `FAIL` with exact command, exit code, and evidence. + +### Required report + +```text +VERDICT: PASS|FAIL +BRANCH: +HEAD: +WORKTREE_CLEAN: yes|no +LFS_ASSETS: PASS|FAIL +YOLOE_WEIGHTS: PASS|FAIL +PYTEST: +RUFF_FORMAT: PASS|FAIL +RUFF_CHECK: PASS|FAIL +MYPY: PASS|FAIL +REAL_DEMO: PASS|FAIL +DETECTOR_REPEAT: PASS|FAIL +BUNDLE_REPEAT: PASS|FAIL +FULL_RUN_REPEAT: PASS|FAIL +TEMPORAL_MEMORY_READY: PASS|FAIL +PRIVACY_BOUNDARY: PASS|FAIL +PATH_SAFETY: PASS|FAIL +SUMMARY_SHA256: +NOTES: +``` + +## Troubleshooting + +### `moov atom not found` + +The video is probably still an LFS pointer. Check its size and run the Git LFS pull command again. + +### `ModuleNotFoundError: No module named 'clip'` + +Install the Ultralytics CLIP fork with the command in Prerequisites. + +### MobileCLIP download starts on first run + +This is expected for YOLO-E text prompting. The file is large and cached after download. + +### `duplicate fallback label` + +The tracker returned multiple detections of one prompt class without native IDs. The adapter rejects this instead of inventing ambiguous identity. Adjust prompts/confidence or use a video with stronger track continuity. + +### `no spatially separated object pairs` + +The saved observations did not produce accepted left/right/above/below relations. Inspect detections, normalized boxes, prompts, sampling stride, and relation margin. + +### `accepted relations produced no evaluation questions` + +The relation set is valid but insufficient for current question generation. Inspect relation IDs and interval ordering rather than bypassing the error. + +### OpenCV AVFoundation duplicate-class warning on macOS + +The `cv2` and `av` wheels may both bundle FFmpeg AVFoundation classes. The reference run emits this warning but completes successfully. Treat a crash or decode failure as an error; do not treat the warning alone as failure. + +### Slow CPU inference + +The reference detector explicitly uses CPU for reproducibility. Increase `--frame-stride` to reduce inference calls for exploratory runs, but do not compare resulting hashes or counts to the reference configuration. + +### Output path rejected + +The demo refuses symlinked roots/children, non-directory roots, source/target aliases, hard-link aliases, and unexpected directory artifacts. Use a fresh regular directory under the repository, such as `.artifacts/spatiotemporal-video-qa`. + +## Current verification record + +- Integration branch: `feat/spatiotemporal-video-qa` +- Demo implementation commit: `1b9f7dae68e2023867dc20e90b4e7dfd122a6e14` +- Completion record commit: `c34658992e9bf40780e26cb92de7a02876430a94` +- Package tests: 108 passed +- Ruff: passed +- mypy: passed across 874 source files +- Independent code review: passed +- Real demo: passed twice with byte-identical summary + +For a shorter operator-oriented version, see `docs/spatiotemporal-video-qa-demo.md`. diff --git a/dimos/benchmark/spatiotemporal/bundles.py b/dimos/benchmark/spatiotemporal/bundles.py new file mode 100644 index 0000000000..be1354cd06 --- /dev/null +++ b/dimos/benchmark/spatiotemporal/bundles.py @@ -0,0 +1,280 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Deterministic public/private evaluation bundle storage.""" + +from collections.abc import Sequence +from dataclasses import dataclass +from hashlib import sha256 +from pathlib import Path +from typing import TypeVar + +from pydantic import BaseModel + +from dimos.benchmark.spatiotemporal.models import ( + BundleArtifact, + ObjectObservation, + OracleAnswer, + OracleBundleManifest, + PublicBundleManifest, + Question, + RelationFact, + RelationInterval, +) +from dimos.benchmark.spatiotemporal.utilities import ( + SCHEMA_VERSION, + canonical_json_bytes, + canonical_model_json, + stable_id, +) + +RecordT = TypeVar("RecordT", bound=BaseModel) + + +@dataclass(frozen=True) +class EvaluationBundle: + """A fully validated public bundle and its private oracle records.""" + + public_manifest: PublicBundleManifest + oracle_manifest: OracleBundleManifest + questions: tuple[Question, ...] + observations: tuple[ObjectObservation, ...] + relation_facts: tuple[RelationFact, ...] + relation_intervals: tuple[RelationInterval, ...] + answers: tuple[OracleAnswer, ...] + + +def _write_jsonl(root: Path, relative_path: str, records: Sequence[BaseModel]) -> BundleArtifact: + path = _safe_path(root, relative_path) + content = b"".join(f"{canonical_model_json(record)}\n".encode() for record in records) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + return BundleArtifact( + path=path.parent.name + "/" + path.name, + sha256=sha256(content).hexdigest(), + record_count=len(records), + ) + + +def _safe_path(root: Path, relative_path: str) -> Path: + path = root + if path.is_symlink(): + raise ValueError(f"bundle path contains a symlink: {relative_path}") + for part in Path(relative_path).parts: + path /= part + if path.is_symlink(): + raise ValueError(f"bundle path contains a symlink: {relative_path}") + return path + + +def _read_jsonl(root: Path, artifact: BundleArtifact, model: type[RecordT]) -> tuple[RecordT, ...]: + path = _safe_path(root, artifact.path) + return tuple(model.model_validate_json(line) for line in path.read_bytes().splitlines()) + + +def _verify_artifact(root: Path, artifact: BundleArtifact) -> None: + content = _safe_path(root, artifact.path).read_bytes() + if sha256(content).hexdigest() != artifact.sha256: + raise ValueError(f"artifact digest mismatch: {artifact.path}") + record_count = len(content.splitlines()) + if record_count != artifact.record_count: + raise ValueError(f"artifact record count mismatch: {artifact.path}") + + +def _verify_episode_metadata(root: Path, manifest: PublicBundleManifest) -> None: + expected = ( + canonical_json_bytes( + { + "bundle_id": manifest.bundle_id, + "episode_id": manifest.episode_id, + "schema_version": manifest.schema_version, + "source_video_sha256": manifest.source_video_sha256, + } + ) + + b"\n" + ) + if (root / manifest.episode.path).read_bytes() != expected: + raise ValueError("episode metadata does not match the public manifest") + + +def _validate_records( + *, + episode_id: str, + questions: Sequence[Question], + observations: Sequence[ObjectObservation], + relation_facts: Sequence[RelationFact], + relation_intervals: Sequence[RelationInterval], + answers: Sequence[OracleAnswer], +) -> None: + question_ids = [question.question_id for question in questions] + if len(set(question_ids)) != len(question_ids): + raise ValueError("duplicate question ID") + answer_ids = [answer.question_id for answer in answers] + if len(set(answer_ids)) != len(answer_ids): + raise ValueError("duplicate oracle answer reference") + if any(answer_id not in set(question_ids) for answer_id in answer_ids): + raise ValueError("oracle answer contains a foreign question ID") + if any(question_id not in set(answer_ids) for question_id in question_ids): + raise ValueError("public question is missing oracle answer") + relation_ids = {fact.relation_id for fact in relation_facts} | { + interval.relation_id for interval in relation_intervals + } + if any( + reference_id not in relation_ids + for question in questions + for reference_id in question.reference_ids + ): + raise ValueError("question contains a foreign relation reference") + interval_id_list = [interval.interval_id for interval in relation_intervals] + interval_ids = set(interval_id_list) + if len(interval_ids) != len(interval_id_list): + raise ValueError("duplicate interval ID") + if any( + interval_id not in interval_ids + for answer in answers + for interval_id in answer.evidence_interval_ids + ): + raise ValueError("oracle answer contains a foreign interval reference") + if ( + any(question.episode_id != episode_id for question in questions) + or any(observation.episode_id != episode_id for observation in observations) + or any(fact.episode_id != episode_id for fact in relation_facts) + or any(interval.episode_id != episode_id for interval in relation_intervals) + ): + raise ValueError("bundle contains a record from a foreign episode") + + +def write_bundle( + root: Path, + *, + episode_id: str, + source_video_sha256: str, + questions: Sequence[Question], + observations: Sequence[ObjectObservation], + relation_facts: Sequence[RelationFact], + relation_intervals: Sequence[RelationInterval], + answers: Sequence[OracleAnswer], +) -> None: + """Write one deterministic public release and its private oracle companion.""" + _validate_records( + episode_id=episode_id, + questions=questions, + observations=observations, + relation_facts=relation_facts, + relation_intervals=relation_intervals, + answers=answers, + ) + + root.mkdir(parents=True, exist_ok=True) + bundle_id = stable_id( + "bundle", + { + "episode_id": episode_id, + "schema_version": SCHEMA_VERSION, + "source_video_sha256": source_video_sha256, + }, + ) + episode_path = _safe_path(root, "public/episode.json") + episode_content = ( + canonical_json_bytes( + { + "bundle_id": bundle_id, + "episode_id": episode_id, + "schema_version": SCHEMA_VERSION, + "source_video_sha256": source_video_sha256, + } + ) + + b"\n" + ) + episode_path.parent.mkdir(parents=True, exist_ok=True) + episode_path.write_bytes(episode_content) + + public_manifest = PublicBundleManifest( + schema_version=SCHEMA_VERSION, + bundle_id=bundle_id, + episode_id=episode_id, + source_video_sha256=source_video_sha256, + episode=BundleArtifact( + path="public/episode.json", + sha256=sha256(episode_content).hexdigest(), + record_count=1, + ), + questions=_write_jsonl(root, "public/questions.jsonl", questions), + ) + public_manifest_bytes = f"{canonical_model_json(public_manifest)}\n".encode() + _safe_path(root, "public/manifest.json").write_bytes(public_manifest_bytes) + + oracle_manifest = OracleBundleManifest( + schema_version=SCHEMA_VERSION, + bundle_id=bundle_id, + episode_id=episode_id, + source_video_sha256=source_video_sha256, + public_manifest_sha256=sha256(public_manifest_bytes).hexdigest(), + observations=_write_jsonl(root, "oracle/observations.jsonl", observations), + relation_facts=_write_jsonl(root, "oracle/relation_facts.jsonl", relation_facts), + relation_intervals=_write_jsonl( + root, "oracle/relation_intervals.jsonl", relation_intervals + ), + answers=_write_jsonl(root, "oracle/answers.jsonl", answers), + ) + _safe_path(root, "oracle/manifest.json").write_text( + f"{canonical_model_json(oracle_manifest)}\n", + encoding="utf-8", + ) + + +def load_bundle(root: Path) -> EvaluationBundle: + """Load one public release together with its private oracle companion.""" + public_manifest_path = _safe_path(root, "public/manifest.json") + oracle_manifest_path = _safe_path(root, "oracle/manifest.json") + public_manifest = PublicBundleManifest.model_validate_json(public_manifest_path.read_bytes()) + oracle_manifest = OracleBundleManifest.model_validate_json(oracle_manifest_path.read_bytes()) + public_manifest_bytes = public_manifest_path.read_bytes() + if sha256(public_manifest_bytes).hexdigest() != oracle_manifest.public_manifest_sha256: + raise ValueError("oracle manifest does not match the public manifest digest") + if ( + oracle_manifest.bundle_id != public_manifest.bundle_id + or oracle_manifest.episode_id != public_manifest.episode_id + or oracle_manifest.source_video_sha256 != public_manifest.source_video_sha256 + ): + raise ValueError("public and oracle manifest identities differ") + artifacts = ( + public_manifest.episode, + public_manifest.questions, + oracle_manifest.observations, + oracle_manifest.relation_facts, + oracle_manifest.relation_intervals, + oracle_manifest.answers, + ) + for artifact in artifacts: + _verify_artifact(root, artifact) + _verify_episode_metadata(root, public_manifest) + bundle = EvaluationBundle( + public_manifest=public_manifest, + oracle_manifest=oracle_manifest, + questions=_read_jsonl(root, public_manifest.questions, Question), + observations=_read_jsonl(root, oracle_manifest.observations, ObjectObservation), + relation_facts=_read_jsonl(root, oracle_manifest.relation_facts, RelationFact), + relation_intervals=_read_jsonl(root, oracle_manifest.relation_intervals, RelationInterval), + answers=_read_jsonl(root, oracle_manifest.answers, OracleAnswer), + ) + _validate_records( + episode_id=public_manifest.episode_id, + questions=bundle.questions, + observations=bundle.observations, + relation_facts=bundle.relation_facts, + relation_intervals=bundle.relation_intervals, + answers=bundle.answers, + ) + return bundle diff --git a/dimos/benchmark/spatiotemporal/demo.py b/dimos/benchmark/spatiotemporal/demo.py new file mode 100644 index 0000000000..a82ec2e8ac --- /dev/null +++ b/dimos/benchmark/spatiotemporal/demo.py @@ -0,0 +1,462 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""One-command real-video spatiotemporal QA demonstration.""" + +from __future__ import annotations + +import argparse +from collections import Counter +from collections.abc import Sequence +from dataclasses import asdict +from hashlib import sha256 +import json +import math +import os +from pathlib import Path +import shutil +from tempfile import TemporaryDirectory +from typing import Any + +import cv2 + +from dimos.benchmark.spatiotemporal.bundles import EvaluationBundle, load_bundle +from dimos.benchmark.spatiotemporal.models import ObjectObservation, Question, QuestionId +from dimos.benchmark.spatiotemporal.observation_io import write_observations +from dimos.benchmark.spatiotemporal.replay import replay_observations +from dimos.benchmark.spatiotemporal.runner import build_evaluation_report +from dimos.benchmark.spatiotemporal.temporal_memory_answerer import TemporalMemoryAnswerer +from dimos.benchmark.spatiotemporal.video_adapter import OpenCVVideoSampler +from dimos.benchmark.spatiotemporal.yoloe_adapter import ( + YoloeAdapterStatistics, + YoloeObservationDetector, +) +from dimos.models.vl.base import VlModel +from dimos.msgs.sensor_msgs.Image import Image, ImageFormat +from dimos.perception.detection.detectors.yoloe import Yoloe2DDetector, YoloePromptMode +from dimos.perception.experimental.temporal_memory.temporal_memory import TemporalMemory + +DEFAULT_PROMPTS = ( + "quadruped robot", + "chair", + "whiteboard", + "table", + "trash can", + "refrigerator", + "door", + "cart", +) + + +class TimestampScriptedPublicVlModel(VlModel): + """Deterministic TemporalMemory plumbing fixture with no private inputs.""" + + def query(self, image: Image, query: str, **kwargs: Any) -> str: + if "new_entities" in query and "entities_present" in query: + timestamp_s = float(image.ts or 0.0) + entities = [{"id": "R1", "type": "appliance", "descriptor": "white refrigerator"}] + if timestamp_s < 10.0: + entities.append({"id": "Q1", "type": "robot", "descriptor": "quadruped robot"}) + else: + entities.append({"id": "C1", "type": "furniture", "descriptor": "office chair"}) + moving_id = "Q1" if timestamp_s < 10.0 else "C1" + relation_type = "left_of" if timestamp_s < 10.0 else "right_of" + return json.dumps( + { + "window": {"start_s": max(0.0, timestamp_s - 4.0), "end_s": timestamp_s}, + "caption": "Robot lab scene with changing robot and chair visibility.", + "entities_present": [{"id": entity["id"]} for entity in entities], + "new_entities": entities, + "relations": [ + { + "type": relation_type, + "subject": moving_id, + "object": "R1", + "confidence": 0.7, + } + ], + "on_screen_text": [], + } + ) + if "rolling summary" in query.lower(): + return "A quadruped robot appeared before an office chair near a refrigerator." + marker = "**Question:** " + public_question = query.split(marker, 1)[1].splitlines()[0] if marker in query else query + return "yes" if int(sha256(public_question.encode()).hexdigest(), 16) % 2 == 0 else "no" + + def query_batch(self, images: list[Image], query: str, **kwargs: Any) -> list[str]: + return [self.query(image, query, **kwargs) for image in images] + + def stop(self) -> None: + return None + + +def _assert_no_symlink_components(path: Path) -> None: + current = Path(path.anchor) if path.is_absolute() else Path.cwd() + parts = path.parts[1:] if path.is_absolute() else path.parts + for part in parts: + current /= part + if current.is_symlink(): + raise ValueError(f"output path contains a symlink: {current}") + + +def _prepare_output_root(output_root: Path) -> Path: + _assert_no_symlink_components(output_root.absolute()) + if output_root.exists() and not output_root.is_dir(): + raise ValueError("output root must be a directory") + output_root.mkdir(parents=True, exist_ok=True) + return output_root.resolve() + + +def _safe_child(output_root: Path, name: str) -> Path: + if Path(name).name != name: + raise ValueError(f"unsafe artifact name: {name}") + path = output_root / name + _assert_no_symlink_components(path) + return path + + +def _reset_directory(path: Path) -> None: + if path.is_symlink(): + raise ValueError(f"refusing to replace symlinked directory: {path}") + if path.exists(): + if not path.is_dir(): + raise ValueError(f"expected directory artifact: {path}") + shutil.rmtree(path) + path.mkdir(parents=True) + + +def _trim_video(source: Path, target: Path, duration_s: float) -> tuple[float, int]: + if source.is_symlink() or not source.is_file(): + raise ValueError(f"source video must be a regular file: {source}") + if target.is_symlink() or target.is_dir(): + raise ValueError(f"target video must be a regular file path: {target}") + if source.resolve() == target.resolve(strict=False) or ( + target.exists() and os.path.samefile(source, target) + ): + raise ValueError("source and target video paths must differ") + capture = cv2.VideoCapture(str(source)) + written = 0 + try: + if not capture.isOpened(): + raise RuntimeError(f"failed to open source video: {source}") + fps = capture.get(cv2.CAP_PROP_FPS) + width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH)) + height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT)) + if not math.isfinite(fps) or fps <= 0.0 or width <= 0 or height <= 0: + raise ValueError("source video has invalid metadata") + frame_limit = round(duration_s * fps) + writer = cv2.VideoWriter( + str(target), + cv2.VideoWriter_fourcc(*"mp4v"), # type: ignore[attr-defined] + fps, + (width, height), + ) + try: + if not writer.isOpened(): + raise RuntimeError(f"failed to open target video writer: {target}") + while written < frame_limit: + decoded, frame = capture.read() + if not decoded: + break + writer.write(frame) + written += 1 + finally: + writer.release() + except Exception: + if target.exists() and not target.is_symlink() and target.is_file(): + target.unlink() + raise + finally: + capture.release() + if written != frame_limit: + if target.exists() and not target.is_symlink() and target.is_file(): + target.unlink() + raise RuntimeError(f"expected {frame_limit} frames, wrote {written}") + verification = cv2.VideoCapture(str(target)) + decoded_frames = 0 + try: + if not verification.isOpened(): + raise RuntimeError(f"failed to reopen encoded video: {target}") + while True: + decoded, _ = verification.read() + if not decoded: + break + decoded_frames += 1 + except Exception: + if target.exists() and not target.is_symlink() and target.is_file(): + target.unlink() + raise + finally: + verification.release() + if decoded_frames != frame_limit: + if target.exists() and not target.is_symlink() and target.is_file(): + target.unlink() + raise RuntimeError( + f"encoded video contains {decoded_frames} decodable frames; expected {frame_limit}" + ) + return fps, written + + +def _detector_factory() -> Yoloe2DDetector: + return Yoloe2DDetector( + prompt_mode=YoloePromptMode.PROMPT, + conf=0.15, + max_area_ratio=0.8, + device="cpu", + ) + + +def _sample_observations( + video: Path, frame_stride: int +) -> tuple[tuple[ObjectObservation, ...], YoloeAdapterStatistics]: + detector = YoloeObservationDetector(prompts=DEFAULT_PROMPTS, detector_factory=_detector_factory) + sampler = OpenCVVideoSampler(detector=detector, frame_stride=frame_stride) + try: + observations = sampler.sample(video, "office_robot_25s") + return observations, detector.statistics + finally: + sampler.close() + + +def _teacher_run( + video: Path, output_root: Path, frame_stride: int +) -> tuple[EvaluationBundle, dict[str, Any]]: + observations_path = _safe_child(output_root, "observations.jsonl") + if observations_path.is_dir(): + raise ValueError("observation artifact path must not be a directory") + first_root = _safe_child(output_root, "bundle-a") + second_root = _safe_child(output_root, "bundle-b") + _reset_directory(first_root) + _reset_directory(second_root) + observations, detector_statistics = _sample_observations(video, frame_stride) + repeated_observations, repeated_statistics = _sample_observations(video, frame_stride) + if observations != repeated_observations or detector_statistics != repeated_statistics: + raise RuntimeError("YOLO-E observations differ across independent detector runs") + write_observations(observations_path, observations) + source_video_sha256 = sha256(video.read_bytes()).hexdigest() + first = replay_observations( + observations_path, + first_root, + source_video_sha256, + ) + second = replay_observations( + observations_path, + second_root, + source_video_sha256, + ) + if first.logical_sha256 != second.logical_sha256: + raise RuntimeError("bundle logical hashes differ across output roots") + first_bundle = load_bundle(first_root) + second_bundle = load_bundle(second_root) + if first_bundle.public_manifest != second_bundle.public_manifest: + raise RuntimeError("public manifests differ across output roots") + if first_bundle.oracle_manifest != second_bundle.oracle_manifest: + raise RuntimeError("oracle manifests differ across output roots") + summary = { + "answers": len(first_bundle.answers), + "bundle_id": first_bundle.public_manifest.bundle_id, + "detector_repeat_equal": True, + "detector_statistics": asdict(detector_statistics), + "labels": dict(sorted(Counter(observation.label for observation in observations).items())), + "logical_sha256": first.logical_sha256, + "observations": len(observations), + "questions": len(first_bundle.questions), + "relation_facts": len(first_bundle.relation_facts), + "relation_intervals": len(first_bundle.relation_intervals), + "sampled_frames": len({observation.frame_id for observation in observations}), + "source_video_sha256": source_video_sha256, + "spatial_questions": sum( + question.question_kind.value == "spatial" for question in first_bundle.questions + ), + "temporal_questions": sum( + question.question_kind.value == "temporal" for question in first_bundle.questions + ), + "unique_object_ids": sorted({observation.object_id for observation in observations}), + } + return first_bundle, summary + + +def _run_temporal_memory_candidate( + video: Path, + questions: Sequence[Question], + output_root: Path, + duration_s: int, +) -> tuple[dict[QuestionId, str | bool | None], dict[str, Any]]: + """Run a public-only candidate and return predictions without oracle access.""" + database_root = _safe_child(output_root, "temporal-memory") + _reset_directory(database_root) + temporal_memory = TemporalMemory( + vlm=TimestampScriptedPublicVlModel(), + db_dir=str(database_root), + new_memory=True, + fps=1.0, + window_s=5.0, + stride_s=5.0, + max_frames_per_window=3, + max_buffer_frames=max(30, duration_s), + summary_interval_s=30.0, + enable_distance_estimation=False, + visualize=False, + use_clip_filtering=False, + ) + + def ingest(path: Path) -> int: + temporal_memory._accumulator.set_start_time(0.0) + capture = cv2.VideoCapture(str(path)) + try: + if not capture.isOpened(): + raise RuntimeError(f"TemporalMemory failed to open video: {path}") + fps = capture.get(cv2.CAP_PROP_FPS) + if not math.isfinite(fps) or fps <= 0.0: + raise ValueError("TemporalMemory video FPS must be finite and positive") + ingested = 0 + for second in range(duration_s): + capture.set(cv2.CAP_PROP_POS_FRAMES, round(second * fps)) + decoded, frame = capture.read() + if not decoded: + raise RuntimeError(f"TemporalMemory failed to decode second {second}") + image = Image.from_numpy( + cv2.cvtColor(frame, cv2.COLOR_BGR2RGB), + format=ImageFormat.RGB, + frame_id=str(round(second * fps)), + ts=float(second), + ) + temporal_memory._accumulator.add_frame(image, float(second)) + ingested += 1 + if ingested % 5 == 0: + temporal_memory._analyze_window() + return ingested + finally: + capture.release() + + answerer = TemporalMemoryAnswerer( + ingest=ingest, + query=temporal_memory.query, + cleanup=temporal_memory.stop, + ) + try: + readiness = answerer.ingest_video(video) + state = temporal_memory.get_state() + candidate_answers = { + question.question_id: answerer.answer(question) for question in questions + } + runtime = { + "no_answers": sum(answer == "no" for answer in candidate_answers.values()), + "questions_answered": len(candidate_answers), + "readiness": readiness.model_dump(mode="json"), + "temporal_memory_state": state, + "vlm_mode": "timestamp_scripted_temporal_memory_plumbing", + "yes_answers": sum(answer == "yes" for answer in candidate_answers.values()), + } + return candidate_answers, runtime + finally: + answerer.close() + + +def _score_candidate( + bundle: EvaluationBundle, + candidate_answers: dict[QuestionId, str | bool | None], + runtime: dict[str, Any], +) -> dict[str, Any]: + """Score public-only predictions in the evaluator's private boundary.""" + oracle_by_question = {answer.question_id: answer for answer in bundle.answers} + report = build_evaluation_report( + bundle.questions, + oracle_by_question, + candidate_answers, + source_video_sha256=bundle.public_manifest.source_video_sha256, + ) + return { + **runtime, + "by_family": {family.value: asdict(score) for family, score in report.by_family.items()}, + "candidate_used_oracle": False, + "overall": asdict(report.overall), + "status_counts": {status.value: count for status, count in report.status_counts.items()}, + } + + +def run_demo( + source_video: Path, + output_root: Path, + *, + duration_s: int = 25, + frame_stride: int = 150, +) -> dict[str, Any]: + """Run the real video, YOLO-E, bundle, replay, and TemporalMemory gates.""" + if not 15 <= duration_s <= 30: + raise ValueError("duration must be between 15 and 30 seconds") + if frame_stride < 1: + raise ValueError("frame stride must be positive") + if source_video.is_symlink() or not source_video.is_file(): + raise ValueError(f"source video must be a regular file: {source_video}") + output_root = _prepare_output_root(output_root) + video = _safe_child(output_root, "office_robot_25s.mp4") + summary_path = _safe_child(output_root, "summary.json") + if summary_path.is_dir(): + raise ValueError("summary artifact path must not be a directory") + fps, frames = _trim_video(source_video, video, float(duration_s)) + bundle, teacher = _teacher_run(video, output_root, frame_stride) + with TemporaryDirectory(prefix="dimos-stqa-candidate-") as candidate_directory: + candidate_root = Path(candidate_directory) + candidate_video = candidate_root / video.name + shutil.copyfile(video, candidate_video) + predictions, runtime = _run_temporal_memory_candidate( + candidate_video, + bundle.questions, + candidate_root, + duration_s, + ) + candidate = _score_candidate(bundle, predictions, runtime) + summary = { + "candidate": candidate, + "teacher": teacher, + "video": { + "duration_s": duration_s, + "fps": fps, + "frames": frames, + "path": str(video), + }, + } + summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n") + return summary + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source-video", type=Path, default=Path("assets/simple_demo.mp4")) + parser.add_argument( + "--output-root", + type=Path, + default=Path(".artifacts/spatiotemporal-video-qa"), + ) + parser.add_argument("--duration-s", type=int, default=25) + parser.add_argument("--frame-stride", type=int, default=150) + args = parser.parse_args() + print( + json.dumps( + run_demo( + args.source_video, + args.output_root, + duration_s=args.duration_s, + frame_stride=args.frame_stride, + ), + indent=2, + sort_keys=True, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/dimos/benchmark/spatiotemporal/generation.py b/dimos/benchmark/spatiotemporal/generation.py new file mode 100644 index 0000000000..860b004013 --- /dev/null +++ b/dimos/benchmark/spatiotemporal/generation.py @@ -0,0 +1,164 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Deterministic public question generation from accepted relation facts.""" + +from collections.abc import Sequence + +from dimos.benchmark.spatiotemporal.models import ( + OracleAnswer, + Question, + QuestionKind, + RelationFact, + RelationInterval, + TemporalPredicate, +) +from dimos.benchmark.spatiotemporal.utilities import SCHEMA_VERSION, stable_id + + +def generate_spatial_questions(facts: Sequence[RelationFact]) -> tuple[Question, ...]: + """Generate one answer-free public question per accepted spatial relation.""" + if len({fact.episode_id for fact in facts}) > 1: + raise ValueError("spatial questions must be generated from one episode") + + questions: dict[str, Question] = {} + for fact in facts: + object_ids = (fact.subject_id, fact.object_id) + question_id = stable_id( + "question", + { + "object_ids": object_ids, + "predicate": fact.predicate.value, + "question_kind": QuestionKind.SPATIAL.value, + "reference_ids": (), + "schema_version": SCHEMA_VERSION, + }, + ) + questions.setdefault( + question_id, + Question( + question_id=question_id, + episode_id=fact.episode_id, + text=f"Is {fact.subject_id} {fact.predicate.value.replace('-', ' ')} {fact.object_id}?", + question_kind=QuestionKind.SPATIAL, + predicate=fact.predicate, + object_ids=object_ids, + ), + ) + return tuple(questions[question_id] for question_id in sorted(questions)) + + +def generate_temporal_question_cases( + intervals: Sequence[RelationInterval], +) -> tuple[tuple[Question, OracleAnswer], ...]: + """Generate balanced temporal cases with private interval-backed truth.""" + if len({interval.episode_id for interval in intervals}) > 1: + raise ValueError("temporal questions must be generated from one episode") + + proofs: dict[ + tuple[str, str], + dict[tuple[str, str], list[tuple[RelationInterval, RelationInterval]]], + ] = {} + for index, left in enumerate(intervals): + for right in intervals[index + 1 :]: + if left.relation_id == right.relation_id: + continue + if ( + left.end_frame_id < right.start_frame_id + and left.end_timestamp_s < right.start_timestamp_s + ): + first, second = left, right + elif ( + right.end_frame_id < left.start_frame_id + and right.end_timestamp_s < left.start_timestamp_s + ): + first, second = right, left + else: + continue + + relation_pair = ( + min(first.relation_id, second.relation_id), + max(first.relation_id, second.relation_id), + ) + orientation = (first.relation_id, second.relation_id) + proofs.setdefault(relation_pair, {}).setdefault(orientation, []).append((first, second)) + + cases: dict[str, tuple[Question, OracleAnswer]] = {} + for relation_pair in sorted(proofs): + orientations = proofs[relation_pair] + if len(orientations) != 1: + continue + interval_proofs = next(iter(orientations.values())) + first, second = min( + interval_proofs, + key=lambda proof: (proof[0].interval_id, proof[1].interval_id), + ) + for predicate, references, expected in ( + ( + TemporalPredicate.BEFORE, + (first.relation_id, second.relation_id), + True, + ), + ( + TemporalPredicate.AFTER, + (second.relation_id, first.relation_id), + True, + ), + ( + TemporalPredicate.AFTER, + (first.relation_id, second.relation_id), + False, + ), + ( + TemporalPredicate.BEFORE, + (second.relation_id, first.relation_id), + False, + ), + ): + question_id = stable_id( + "question", + { + "object_ids": (), + "predicate": predicate.value, + "question_kind": QuestionKind.TEMPORAL.value, + "reference_ids": references, + "schema_version": SCHEMA_VERSION, + }, + ) + question = Question( + question_id=question_id, + episode_id=first.episode_id, + text=(f"Did {references[0]} happen {predicate.value} {references[1]}?"), + question_kind=QuestionKind.TEMPORAL, + predicate=predicate, + object_ids=(), + reference_ids=references, + ) + cases[question_id] = ( + question, + OracleAnswer( + question_id=question_id, + expected=expected, + evidence_interval_ids=(first.interval_id, second.interval_id), + ), + ) + + return tuple(cases[question_id] for question_id in sorted(cases)) + + +def generate_temporal_questions( + intervals: Sequence[RelationInterval], +) -> tuple[Question, ...]: + """Generate public questions for unambiguous strict interval orderings.""" + return tuple(question for question, _ in generate_temporal_question_cases(intervals)) diff --git a/dimos/benchmark/spatiotemporal/intervals.py b/dimos/benchmark/spatiotemporal/intervals.py new file mode 100644 index 0000000000..0be905019a --- /dev/null +++ b/dimos/benchmark/spatiotemporal/intervals.py @@ -0,0 +1,138 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Deterministic construction of relation intervals.""" + +from collections.abc import Sequence +from itertools import pairwise + +from dimos.benchmark.spatiotemporal.models import ( + RelationFact, + RelationId, + RelationInterval, + TemporalPredicate, +) +from dimos.benchmark.spatiotemporal.utilities import SCHEMA_VERSION, JsonValue, stable_id + + +def _validate_sample_schedules(facts: Sequence[RelationFact]) -> None: + by_episode: dict[str, dict[int, float]] = {} + timestamps_by_episode: dict[str, dict[float, int]] = {} + for fact in facts: + frames = by_episode.setdefault(fact.episode_id, {}) + timestamps = timestamps_by_episode.setdefault(fact.episode_id, {}) + if (fact.frame_id in frames and frames[fact.frame_id] != fact.timestamp_s) or ( + fact.timestamp_s in timestamps and timestamps[fact.timestamp_s] != fact.frame_id + ): + raise ValueError("conflicting sample schedule") + frames[fact.frame_id] = fact.timestamp_s + timestamps[fact.timestamp_s] = fact.frame_id + + for frames in by_episode.values(): + ordered_samples = sorted(frames.items()) + if any( + current_timestamp >= next_timestamp + for (_, current_timestamp), (_, next_timestamp) in pairwise(ordered_samples) + ): + raise ValueError("conflicting sample schedule") + + +def _build_interval(facts: Sequence[RelationFact]) -> RelationInterval: + first = facts[0] + last = facts[-1] + preimage: dict[str, JsonValue] = { + "end_frame_id": last.frame_id, + "end_timestamp_s": last.timestamp_s, + "episode_id": first.episode_id, + "object_id": first.object_id, + "predicate": first.predicate.value, + "relation_id": first.relation_id, + "schema_version": SCHEMA_VERSION, + "start_frame_id": first.frame_id, + "start_timestamp_s": first.timestamp_s, + "subject_id": first.subject_id, + } + return RelationInterval( + interval_id=stable_id("interval", preimage), + relation_id=first.relation_id, + episode_id=first.episode_id, + subject_id=first.subject_id, + predicate=first.predicate, + object_id=first.object_id, + start_frame_id=first.frame_id, + end_frame_id=last.frame_id, + start_timestamp_s=first.timestamp_s, + end_timestamp_s=last.timestamp_s, + evidence_frame_ids=tuple(fact.frame_id for fact in facts), + ) + + +def build_relation_intervals(facts: Sequence[RelationFact]) -> tuple[RelationInterval, ...]: + """Coalesce consecutive facts that share episode and relation identity.""" + _validate_sample_schedules(facts) + ordered = sorted( + facts, + key=lambda fact: ( + fact.episode_id, + fact.relation_id, + fact.frame_id, + fact.timestamp_s, + ), + ) + groups: list[list[RelationFact]] = [] + for fact in ordered: + if ( + groups + and fact.episode_id == groups[-1][-1].episode_id + and fact.relation_id == groups[-1][-1].relation_id + and fact.frame_id == groups[-1][-1].frame_id + 1 + ): + groups[-1].append(fact) + else: + groups.append([fact]) + return tuple(_build_interval(group) for group in groups) + + +def derive_temporal_predicate( + first_relation_id: RelationId, + second_relation_id: RelationId, + intervals: Sequence[RelationInterval], +) -> TemporalPredicate | None: + """Return a strict order only when all matching interval evidence agrees.""" + first = [interval for interval in intervals if interval.relation_id == first_relation_id] + second = [interval for interval in intervals if interval.relation_id == second_relation_id] + first_episodes = {interval.episode_id for interval in first} + second_episodes = {interval.episode_id for interval in second} + if not first or not second or first_episodes != second_episodes: + return None + + predicates: set[TemporalPredicate] = set() + for first_interval in first: + for second_interval in second: + if first_interval.episode_id != second_interval.episode_id: + continue + if ( + first_interval.end_frame_id < second_interval.start_frame_id + and first_interval.end_timestamp_s < second_interval.start_timestamp_s + ): + predicates.add(TemporalPredicate.BEFORE) + elif ( + second_interval.end_frame_id < first_interval.start_frame_id + and second_interval.end_timestamp_s < first_interval.start_timestamp_s + ): + predicates.add(TemporalPredicate.AFTER) + else: + return None + + return predicates.pop() if len(predicates) == 1 else None diff --git a/dimos/benchmark/spatiotemporal/models.py b/dimos/benchmark/spatiotemporal/models.py new file mode 100644 index 0000000000..841542219b --- /dev/null +++ b/dimos/benchmark/spatiotemporal/models.py @@ -0,0 +1,395 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Strict data contracts for spatiotemporal video QA.""" + +from enum import StrEnum +from pathlib import PurePosixPath +from typing import Annotated, Self +import unicodedata + +from pydantic import AfterValidator, BaseModel, ConfigDict, Field, model_validator + +from dimos.benchmark.spatiotemporal.utilities import SCHEMA_VERSION, stable_id + + +def _require_nfc(value: str) -> str: + try: + value.encode("utf-8") + except UnicodeEncodeError as error: + raise ValueError("string fields must contain valid Unicode scalar values") from error + if not unicodedata.is_normalized("NFC", value): + raise ValueError("string fields must be NFC-normalized") + return value + + +NonEmptyString = Annotated[str, Field(min_length=1), AfterValidator(_require_nfc)] +QuestionId = Annotated[ + str, + Field(pattern=r"^question_[0-9a-f]{64}$"), + AfterValidator(_require_nfc), +] +RelationId = Annotated[ + str, + Field(pattern=r"^relation_[0-9a-f]{64}$"), + AfterValidator(_require_nfc), +] +IntervalId = Annotated[ + str, + Field(pattern=r"^interval_[0-9a-f]{64}$"), + AfterValidator(_require_nfc), +] +BundleId = Annotated[ + str, + Field(pattern=r"^bundle_[0-9a-f]{64}$"), + AfterValidator(_require_nfc), +] +Sha256Digest = Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")] + + +class SpatialPredicate(StrEnum): + """Supported image-plane predicates.""" + + LEFT_OF = "left-of" + RIGHT_OF = "right-of" + ABOVE = "above" + BELOW = "below" + + +class TemporalPredicate(StrEnum): + """Supported strict interval predicates.""" + + BEFORE = "before" + AFTER = "after" + + +class QuestionKind(StrEnum): + """Supported public question categories.""" + + SPATIAL = "spatial" + TEMPORAL = "temporal" + + +class PredictionStatus(StrEnum): + """Closed set of candidate scoring outcomes.""" + + CORRECT = "correct" + INCORRECT = "incorrect" + MISSING = "missing" + INVALID = "invalid" + + +class StrictFrozenModel(BaseModel): + """Base for immutable benchmark records with no implicit coercion.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + +class BoundingBox2D(StrictFrozenModel): + """A normalized image-plane bounding box.""" + + x_min: float = Field(ge=0.0, le=1.0, allow_inf_nan=False) + y_min: float = Field(ge=0.0, le=1.0, allow_inf_nan=False) + x_max: float = Field(ge=0.0, le=1.0, allow_inf_nan=False) + y_max: float = Field(ge=0.0, le=1.0, allow_inf_nan=False) + + @model_validator(mode="after") + def validate_bounds(self) -> Self: + """Reject empty or inverted boxes.""" + if self.x_min >= self.x_max or self.y_min >= self.y_max: + raise ValueError("bounding box minimums must be below maximums") + return self + + +class ObjectObservation(StrictFrozenModel): + """One object's canonical observation in a sampled video frame.""" + + episode_id: NonEmptyString + frame_id: int = Field(ge=0) + timestamp_s: float = Field(allow_inf_nan=False) + object_id: NonEmptyString + label: NonEmptyString + box: BoundingBox2D + confidence: float = Field(ge=0.0, le=1.0, allow_inf_nan=False) + + +class RelationFact(StrictFrozenModel): + """One private accepted spatial relation at a sampled frame.""" + + relation_id: RelationId + episode_id: NonEmptyString + frame_id: int = Field(ge=0) + timestamp_s: float = Field(allow_inf_nan=False) + subject_id: NonEmptyString + predicate: SpatialPredicate + object_id: NonEmptyString + evidence_frame_ids: tuple[int, ...] = Field(min_length=1) + + @model_validator(mode="after") + def validate_relation_contract(self) -> Self: + if self.subject_id == self.object_id: + raise ValueError("relation subject and object IDs must differ") + if any(frame_id < 0 for frame_id in self.evidence_frame_ids): + raise ValueError("evidence frame IDs must be non-negative") + if tuple(sorted(set(self.evidence_frame_ids))) != self.evidence_frame_ids: + raise ValueError("evidence frame IDs must be ordered and unique") + if self.evidence_frame_ids != (self.frame_id,): + raise ValueError("a relation fact must cite exactly its own sample frame") + expected_id = stable_id( + "relation", + { + "object_ids": (self.subject_id, self.object_id), + "predicate": self.predicate.value, + "schema_version": SCHEMA_VERSION, + }, + ) + if self.relation_id != expected_id: + raise ValueError("relation ID does not match its executable contract") + return self + + +class RelationInterval(StrictFrozenModel): + """One private bounded interval of a stable accepted relation.""" + + interval_id: IntervalId + relation_id: RelationId + episode_id: NonEmptyString + subject_id: NonEmptyString + predicate: SpatialPredicate + object_id: NonEmptyString + start_frame_id: int = Field(ge=0) + end_frame_id: int = Field(ge=0) + start_timestamp_s: float = Field(allow_inf_nan=False) + end_timestamp_s: float = Field(allow_inf_nan=False) + evidence_frame_ids: tuple[int, ...] = Field(min_length=1) + + @model_validator(mode="after") + def validate_interval_contract(self) -> Self: + if self.subject_id == self.object_id: + raise ValueError("relation subject and object IDs must differ") + if self.start_frame_id > self.end_frame_id: + raise ValueError("interval frame bounds must be ordered") + if self.start_timestamp_s > self.end_timestamp_s: + raise ValueError("interval timestamp bounds must be ordered") + if (self.start_frame_id == self.end_frame_id) != ( + self.start_timestamp_s == self.end_timestamp_s + ): + raise ValueError("interval frame and timestamp bounds must identify the same samples") + if tuple(sorted(set(self.evidence_frame_ids))) != self.evidence_frame_ids: + raise ValueError("evidence frame IDs must be ordered and unique") + if self.evidence_frame_ids[0] != self.start_frame_id: + raise ValueError("first evidence frame must equal interval start") + if self.evidence_frame_ids[-1] != self.end_frame_id: + raise ValueError("last evidence frame must equal interval end") + expected_relation_id = stable_id( + "relation", + { + "object_ids": (self.subject_id, self.object_id), + "predicate": self.predicate.value, + "schema_version": SCHEMA_VERSION, + }, + ) + if self.relation_id != expected_relation_id: + raise ValueError("relation ID does not match interval relation identity") + expected_interval_id = stable_id( + "interval", + { + "end_frame_id": self.end_frame_id, + "end_timestamp_s": self.end_timestamp_s, + "episode_id": self.episode_id, + "object_id": self.object_id, + "predicate": self.predicate.value, + "relation_id": self.relation_id, + "schema_version": SCHEMA_VERSION, + "start_frame_id": self.start_frame_id, + "start_timestamp_s": self.start_timestamp_s, + "subject_id": self.subject_id, + }, + ) + if self.interval_id != expected_interval_id: + raise ValueError("interval ID does not match its executable contract") + return self + + +class Question(StrictFrozenModel): + """A public spatial or temporal question with an answer-free contract.""" + + question_id: QuestionId + episode_id: NonEmptyString + text: NonEmptyString + question_kind: QuestionKind + predicate: SpatialPredicate | TemporalPredicate + object_ids: tuple[NonEmptyString, NonEmptyString] | tuple[()] + reference_ids: tuple[RelationId, RelationId] | tuple[()] = () + + @model_validator(mode="after") + def validate_executable_contract(self) -> Self: + """Enforce disjoint variants and IDs derived only from public semantics.""" + if self.question_kind is QuestionKind.SPATIAL: + if not isinstance(self.predicate, SpatialPredicate): + raise ValueError("spatial questions require a spatial predicate") + if len(self.object_ids) != 2 or self.object_ids[0] == self.object_ids[1]: + raise ValueError("spatial question object IDs must differ") + if self.reference_ids: + raise ValueError("spatial questions cannot contain relation references") + else: + if not isinstance(self.predicate, TemporalPredicate): + raise ValueError("temporal questions require a temporal predicate") + if self.object_ids: + raise ValueError("temporal questions cannot contain object IDs") + if len(self.reference_ids) != 2 or self.reference_ids[0] == self.reference_ids[1]: + raise ValueError("temporal question relation references must differ") + expected_id = stable_id( + "question", + { + "object_ids": self.object_ids, + "predicate": self.predicate.value, + "question_kind": self.question_kind.value, + "reference_ids": self.reference_ids, + "schema_version": SCHEMA_VERSION, + }, + ) + if self.question_id != expected_id: + raise ValueError("question ID does not match its executable contract") + return self + + +class OracleAnswer(StrictFrozenModel): + """A private expected answer and its teacher evidence.""" + + question_id: QuestionId + expected: bool + evidence_frame_ids: tuple[int, ...] = () + evidence_interval_ids: tuple[IntervalId, ...] = () + + @model_validator(mode="after") + def validate_evidence(self) -> Self: + if any(frame_id < 0 for frame_id in self.evidence_frame_ids): + raise ValueError("evidence frame IDs must be non-negative") + if tuple(sorted(set(self.evidence_frame_ids))) != self.evidence_frame_ids: + raise ValueError("evidence frame IDs must be ordered and unique") + if len(set(self.evidence_interval_ids)) != len(self.evidence_interval_ids): + raise ValueError("evidence interval IDs must be unique") + if not self.evidence_frame_ids and not self.evidence_interval_ids: + raise ValueError("oracle answers require private evidence") + return self + + +class Prediction(StrictFrozenModel): + """One candidate's typed Boolean answer.""" + + question_id: QuestionId + answer: bool + + +class QuestionResult(StrictFrozenModel): + """Exact result for one question.""" + + question_id: QuestionId + expected: bool + predicted: bool + correct: bool + + +class BundleArtifact(StrictFrozenModel): + """One canonical file referenced by a release manifest.""" + + path: NonEmptyString + sha256: Sha256Digest + record_count: int = Field(ge=0) + + @model_validator(mode="after") + def validate_path(self) -> Self: + path = PurePosixPath(self.path) + if ( + path.is_absolute() + or "\\" in self.path + or path.as_posix() != self.path + or any(part in {"", ".", ".."} for part in path.parts) + ): + raise ValueError("artifact path must be a relative canonical path") + return self + + +class PublicBundleManifest(StrictFrozenModel): + """Public release metadata containing no teacher-derived artifacts.""" + + schema_version: NonEmptyString + bundle_id: BundleId + episode_id: NonEmptyString + source_video_sha256: Sha256Digest + episode: BundleArtifact + questions: BundleArtifact + + @model_validator(mode="after") + def validate_manifest(self) -> Self: + if self.schema_version != SCHEMA_VERSION: + raise ValueError("unsupported schema version") + expected_bundle_id = stable_id( + "bundle", + { + "episode_id": self.episode_id, + "schema_version": self.schema_version, + "source_video_sha256": self.source_video_sha256, + }, + ) + if self.bundle_id != expected_bundle_id: + raise ValueError("bundle ID does not match public release identity") + if not self.episode.path.startswith("public/") or not self.questions.path.startswith( + "public/" + ): + raise ValueError("public manifest artifacts must remain under public/") + if self.episode.path == self.questions.path: + raise ValueError("public manifest artifacts must have distinct paths") + return self + + +class OracleBundleManifest(StrictFrozenModel): + """Private release metadata bound to an exact public release.""" + + schema_version: NonEmptyString + bundle_id: BundleId + episode_id: NonEmptyString + source_video_sha256: Sha256Digest + public_manifest_sha256: Sha256Digest + observations: BundleArtifact + relation_facts: BundleArtifact + relation_intervals: BundleArtifact + answers: BundleArtifact + + @model_validator(mode="after") + def validate_manifest(self) -> Self: + if self.schema_version != SCHEMA_VERSION: + raise ValueError("unsupported schema version") + expected_bundle_id = stable_id( + "bundle", + { + "episode_id": self.episode_id, + "schema_version": self.schema_version, + "source_video_sha256": self.source_video_sha256, + }, + ) + if self.bundle_id != expected_bundle_id: + raise ValueError("bundle ID does not match oracle release identity") + artifacts = ( + self.observations, + self.relation_facts, + self.relation_intervals, + self.answers, + ) + if any(not artifact.path.startswith("oracle/") for artifact in artifacts): + raise ValueError("oracle manifest artifacts must remain under oracle/") + if len({artifact.path for artifact in artifacts}) != len(artifacts): + raise ValueError("oracle manifest artifacts must have distinct paths") + return self diff --git a/dimos/benchmark/spatiotemporal/observation_io.py b/dimos/benchmark/spatiotemporal/observation_io.py new file mode 100644 index 0000000000..2ddb3e274b --- /dev/null +++ b/dimos/benchmark/spatiotemporal/observation_io.py @@ -0,0 +1,105 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Canonical JSONL persistence for private teacher observations.""" + +from collections.abc import Iterable +import json +from pathlib import Path +from typing import Any + +from pydantic import ValidationError + +from dimos.benchmark.spatiotemporal.models import ObjectObservation +from dimos.benchmark.spatiotemporal.utilities import canonical_model_json + + +def _reject_nonfinite_json(constant: str) -> None: + raise ValueError(f"invalid JSON numeric constant: {constant}") + + +def _reject_duplicate_json_fields(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + value: dict[str, Any] = {} + for key, item in pairs: + if key in value: + raise ValueError(f"duplicate JSON field: {key}") + value[key] = item + return value + + +def _identity(observation: ObjectObservation) -> tuple[str, int, str]: + return observation.episode_id, observation.frame_id, observation.object_id + + +def _validated_observations( + observations: Iterable[ObjectObservation], +) -> tuple[ObjectObservation, ...]: + by_identity: dict[tuple[str, int, str], ObjectObservation] = {} + for observation in observations: + try: + observation = ObjectObservation.model_validate(observation.model_dump()) + except ValidationError as error: + raise ValueError(f"invalid observation: {error}") from error + if observation.timestamp_s < 0.0: + raise ValueError("observation timestamp must be non-negative") + + identity = _identity(observation) + previous = by_identity.get(identity) + if previous is not None: + qualifier = "duplicate" if previous == observation else "conflicting" + raise ValueError(f"{qualifier} observation identity: {identity}") + by_identity[identity] = observation + + return tuple(sorted(by_identity.values(), key=_identity)) + + +def write_observations(path: Path, observations: Iterable[ObjectObservation]) -> None: + """Write observations as deterministic, identity-ordered canonical JSONL.""" + ordered = _validated_observations(observations) + document = "".join(f"{canonical_model_json(observation)}\n" for observation in ordered) + path.write_bytes(document.encode("utf-8")) + + +def read_observations(path: Path) -> tuple[ObjectObservation, ...]: + """Read and strictly validate canonical teacher observations from JSONL.""" + try: + document = path.read_bytes().decode("utf-8") + except UnicodeDecodeError as error: + raise ValueError("observation JSONL must be valid UTF-8") from error + if document and not document.endswith("\n"): + raise ValueError("observation JSONL must end with a canonical newline") + + lines = document[:-1].split("\n") if document else [] + observations: list[ObjectObservation] = [] + for line_number, line in enumerate(lines, start=1): + try: + value = json.loads( + line, + object_pairs_hook=_reject_duplicate_json_fields, + parse_constant=_reject_nonfinite_json, + ) + except (json.JSONDecodeError, ValueError) as error: + raise ValueError(f"invalid JSON at line {line_number}: {error}") from error + try: + observation = ObjectObservation.model_validate(value) + except ValidationError as error: + raise ValueError(f"invalid observation at line {line_number}: {error}") from error + if line != canonical_model_json(observation): + raise ValueError(f"non-canonical observation JSON at line {line_number}") + observations.append(observation) + + ordered = _validated_observations(observations) + if tuple(observations) != ordered: + raise ValueError("observation JSONL records are not in canonical identity order") + return ordered diff --git a/dimos/benchmark/spatiotemporal/ports.py b/dimos/benchmark/spatiotemporal/ports.py new file mode 100644 index 0000000000..98b45c1a05 --- /dev/null +++ b/dimos/benchmark/spatiotemporal/ports.py @@ -0,0 +1,155 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Frozen callable boundaries for perception and candidate evaluation.""" + +from collections.abc import Sequence +from enum import StrEnum +from hashlib import sha256 +from pathlib import Path +from typing import Protocol, Self + +from pydantic import Field, model_validator + +from dimos.benchmark.spatiotemporal.models import ( + BoundingBox2D, + NonEmptyString, + ObjectObservation, + OracleBundleManifest, + PublicBundleManifest, + Question, + Sha256Digest, + StrictFrozenModel, +) +from dimos.benchmark.spatiotemporal.utilities import canonical_json_bytes, canonical_model_json +from dimos.msgs.sensor_msgs.Image import Image + + +class DetectedObject(StrictFrozenModel): + """One detector result containing only perception-seam data.""" + + object_id: NonEmptyString + label: NonEmptyString + box: BoundingBox2D + confidence: float = Field(ge=0.0, le=1.0, allow_inf_nan=False) + + +class CandidateReadiness(StrictFrozenModel): + """Candidate ingestion state with no teacher or oracle artifacts.""" + + ready: bool + ingested_frame_count: int = Field(ge=0) + detail: NonEmptyString | None = None + + @model_validator(mode="after") + def validate_readiness(self) -> "CandidateReadiness": + if self.ready and self.ingested_frame_count == 0: + raise ValueError("a ready candidate must ingest at least one frame") + return self + + +class ReplayInsufficiencyCode(StrEnum): + """Stable reasons that saved observations cannot produce an evaluation bundle.""" + + EMPTY_OBSERVATIONS = "empty_observations" + MIXED_EPISODES = "mixed_episodes" + NO_RELATIONS = "no_relations" + NO_QUESTIONS = "no_questions" + + +class ReplayInsufficiencyError(ValueError): + """Actionable replay failure with a machine-readable stable reason.""" + + def __init__(self, code: ReplayInsufficiencyCode, message: str) -> None: + self.code = code + super().__init__(message) + + +def replay_bundle_logical_sha256( + public_manifest: PublicBundleManifest, + oracle_manifest: OracleBundleManifest, +) -> str: + """Hash canonical manifest values without extraction-root paths.""" + return sha256( + canonical_json_bytes( + { + "oracle_manifest": oracle_manifest.model_dump(mode="json"), + "public_manifest": public_manifest.model_dump(mode="json"), + } + ) + ).hexdigest() + + +class ReplayBundleResult(StrictFrozenModel): + """Root-independent manifests and logical hash returned by replay generation.""" + + public_manifest: PublicBundleManifest + oracle_manifest: OracleBundleManifest + logical_sha256: Sha256Digest + + @model_validator(mode="after") + def validate_result(self) -> Self: + public = self.public_manifest + oracle = self.oracle_manifest + identities = { + ( + public.schema_version, + public.bundle_id, + public.episode_id, + public.source_video_sha256, + ), + ( + oracle.schema_version, + oracle.bundle_id, + oracle.episode_id, + oracle.source_video_sha256, + ), + } + if len(identities) != 1: + raise ValueError("public and oracle manifests must share one release identity") + expected_public_sha256 = sha256(f"{canonical_model_json(public)}\n".encode()).hexdigest() + if oracle.public_manifest_sha256 != expected_public_sha256: + raise ValueError("oracle manifest must bind the canonical public manifest") + if self.logical_sha256 != replay_bundle_logical_sha256(public, oracle): + raise ValueError("logical SHA-256 does not match canonical manifests") + return self + + +class ObservationBundleGenerator(Protocol): + """Teacher-side seam that turns canonical observations into one bundle.""" + + def generate( + self, + observations: Sequence[ObjectObservation], + output_root: Path, + source_video_sha256: str, + ) -> ReplayBundleResult: ... + + +class ObservationDetector(Protocol): + """Narrow image-to-detections seam used by video sampling.""" + + def detect(self, image: Image) -> Sequence[DetectedObject]: ... + + def close(self) -> None: ... + + +class CandidateAnswerer(Protocol): + """Public-only video ingestion and question answering seam.""" + + def ingest_video(self, video_path: Path) -> CandidateReadiness: ... + + def answer(self, question: Question) -> str | bool | None: ... + + def close(self) -> None: ... diff --git a/dimos/benchmark/spatiotemporal/relations.py b/dimos/benchmark/spatiotemporal/relations.py new file mode 100644 index 0000000000..65837ed003 --- /dev/null +++ b/dimos/benchmark/spatiotemporal/relations.py @@ -0,0 +1,112 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Image-plane relation derivation.""" + +from dataclasses import dataclass +from math import isfinite +from typing import Literal + +from dimos.benchmark.spatiotemporal.models import ObjectObservation, SpatialPredicate + + +@dataclass(frozen=True) +class SpatialRelationCandidate: + """A temporary accepted relation backed by two observations.""" + + subject: ObjectObservation + predicate: SpatialPredicate + object: ObjectObservation + margin: float + + +def _derive_ordered( + subject: ObjectObservation, + object_: ObjectObservation, + margin: float, + predicate: SpatialPredicate, + axis: Literal["x", "y"], + *, + reverse: bool = False, +) -> SpatialRelationCandidate | None: + if not isfinite(margin) or not 0.0 <= margin <= 1.0: + raise ValueError("margin must be finite and within [0, 1]") + subject_sample = (subject.episode_id, subject.frame_id, subject.timestamp_s) + object_sample = (object_.episode_id, object_.frame_id, object_.timestamp_s) + if subject_sample != object_sample: + raise ValueError("relations require observations from the same sample") + if subject.object_id == object_.object_id: + return None + before, after = (object_, subject) if reverse else (subject, object_) + before_max = getattr(before.box, f"{axis}_max") + after_min = getattr(after.box, f"{axis}_min") + separation = after_min - before_max + if separation > margin: + return SpatialRelationCandidate( + subject=subject, + predicate=predicate, + object=object_, + margin=margin, + ) + return None + + +def derive_left_of( + subject: ObjectObservation, + object_: ObjectObservation, + margin: float, +) -> SpatialRelationCandidate | None: + """Derive an accepted left-of candidate when evidence is sufficient.""" + return _derive_ordered(subject, object_, margin, SpatialPredicate.LEFT_OF, "x") + + +def derive_right_of( + subject: ObjectObservation, + object_: ObjectObservation, + margin: float, +) -> SpatialRelationCandidate | None: + """Derive right-of by reusing left-of with swapped arguments.""" + return _derive_ordered( + subject, + object_, + margin, + SpatialPredicate.RIGHT_OF, + "x", + reverse=True, + ) + + +def derive_above( + subject: ObjectObservation, + object_: ObjectObservation, + margin: float, +) -> SpatialRelationCandidate | None: + """Derive an accepted above candidate when evidence is sufficient.""" + return _derive_ordered(subject, object_, margin, SpatialPredicate.ABOVE, "y") + + +def derive_below( + subject: ObjectObservation, + object_: ObjectObservation, + margin: float, +) -> SpatialRelationCandidate | None: + """Derive below by reusing above with swapped arguments.""" + return _derive_ordered( + subject, + object_, + margin, + SpatialPredicate.BELOW, + "y", + reverse=True, + ) diff --git a/dimos/benchmark/spatiotemporal/replay.py b/dimos/benchmark/spatiotemporal/replay.py new file mode 100644 index 0000000000..c7dc1da3cd --- /dev/null +++ b/dimos/benchmark/spatiotemporal/replay.py @@ -0,0 +1,200 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Deterministic replay of saved observations into evaluation bundles.""" + +from collections import defaultdict +from collections.abc import Sequence +from itertools import permutations +from pathlib import Path +from typing import cast + +from dimos.benchmark.spatiotemporal.bundles import load_bundle, write_bundle +from dimos.benchmark.spatiotemporal.generation import ( + generate_spatial_questions, + generate_temporal_question_cases, +) +from dimos.benchmark.spatiotemporal.intervals import build_relation_intervals +from dimos.benchmark.spatiotemporal.models import ( + ObjectObservation, + OracleAnswer, + Question, + RelationFact, +) +from dimos.benchmark.spatiotemporal.observation_io import read_observations +from dimos.benchmark.spatiotemporal.ports import ( + ObservationBundleGenerator, + ReplayBundleResult, + ReplayInsufficiencyCode, + ReplayInsufficiencyError, + replay_bundle_logical_sha256, +) +from dimos.benchmark.spatiotemporal.relations import ( + SpatialRelationCandidate, + derive_above, + derive_below, + derive_left_of, + derive_right_of, +) +from dimos.benchmark.spatiotemporal.utilities import SCHEMA_VERSION, stable_id + +_RELATION_DERIVERS = (derive_left_of, derive_right_of, derive_above, derive_below) + + +def _relation_facts( + observations: Sequence[ObjectObservation], margin: float +) -> tuple[RelationFact, ...]: + by_sample: dict[tuple[int, float], list[ObjectObservation]] = defaultdict(list) + for observation in observations: + by_sample[(observation.frame_id, observation.timestamp_s)].append(observation) + + facts: dict[tuple[int, str], RelationFact] = {} + for sample in by_sample.values(): + for subject, object_ in permutations(sample, 2): + for derive in _RELATION_DERIVERS: + candidate = derive(subject, object_, margin) + if candidate is None: + continue + fact = _relation_fact(candidate) + facts[(fact.frame_id, fact.relation_id)] = fact + return tuple(facts[key] for key in sorted(facts)) + + +def _relation_fact(candidate: SpatialRelationCandidate) -> RelationFact: + subject = candidate.subject + object_ = candidate.object + relation_id = stable_id( + "relation", + { + "object_ids": (subject.object_id, object_.object_id), + "predicate": candidate.predicate.value, + "schema_version": SCHEMA_VERSION, + }, + ) + return RelationFact( + relation_id=relation_id, + episode_id=subject.episode_id, + frame_id=subject.frame_id, + timestamp_s=subject.timestamp_s, + subject_id=subject.object_id, + predicate=candidate.predicate, + object_id=object_.object_id, + evidence_frame_ids=(subject.frame_id,), + ) + + +def _spatial_answer(question: Question, facts: Sequence[RelationFact]) -> OracleAnswer: + subject_id, object_id = cast("tuple[str, str]", question.object_ids) + return OracleAnswer( + question_id=question.question_id, + expected=True, + evidence_frame_ids=tuple( + sorted( + { + fact.frame_id + for fact in facts + if fact.subject_id == subject_id + and fact.object_id == object_id + and fact.predicate == question.predicate + } + ) + ), + ) + + +class DeterministicObservationBundleGenerator: + """Generate deterministic bundles from canonical object observations.""" + + def __init__(self, *, relation_margin: float = 0.0) -> None: + self._relation_margin = relation_margin + + def generate( + self, + observations: Sequence[ObjectObservation], + output_root: Path, + source_video_sha256: str, + ) -> ReplayBundleResult: + """Generate one public/oracle evaluation bundle.""" + if not observations: + raise ReplayInsufficiencyError( + ReplayInsufficiencyCode.EMPTY_OBSERVATIONS, + "saved observations are empty; capture at least one sampled frame", + ) + episodes = {observation.episode_id for observation in observations} + if len(episodes) != 1: + raise ReplayInsufficiencyError( + ReplayInsufficiencyCode.MIXED_EPISODES, + "saved observations contain multiple episodes; replay one episode at a time", + ) + + facts = _relation_facts(observations, self._relation_margin) + if not facts: + raise ReplayInsufficiencyError( + ReplayInsufficiencyCode.NO_RELATIONS, + "saved observations contain no spatially separated object pairs", + ) + intervals = build_relation_intervals(facts) + spatial_questions = generate_spatial_questions(facts) + temporal_cases = generate_temporal_question_cases(intervals) + questions = tuple( + sorted( + (*spatial_questions, *(question for question, _ in temporal_cases)), + key=lambda question: question.question_id, + ) + ) + if not questions: + raise ReplayInsufficiencyError( + ReplayInsufficiencyCode.NO_QUESTIONS, + "accepted relations produced no evaluation questions; inspect generation inputs", + ) + spatial_answers = tuple(_spatial_answer(question, facts) for question in spatial_questions) + answers = tuple( + sorted( + (*spatial_answers, *(answer for _, answer in temporal_cases)), + key=lambda answer: answer.question_id, + ) + ) + episode_id = next(iter(episodes)) + write_bundle( + output_root, + episode_id=episode_id, + source_video_sha256=source_video_sha256, + questions=questions, + observations=observations, + relation_facts=facts, + relation_intervals=intervals, + answers=answers, + ) + bundle = load_bundle(output_root) + return ReplayBundleResult( + public_manifest=bundle.public_manifest, + oracle_manifest=bundle.oracle_manifest, + logical_sha256=replay_bundle_logical_sha256( + bundle.public_manifest, bundle.oracle_manifest + ), + ) + + +def replay_observations( + observations_path: Path, + output_root: Path, + source_video_sha256: str, + *, + generator: ObservationBundleGenerator | None = None, +) -> ReplayBundleResult: + """Read canonical saved observations and generate an evaluation bundle.""" + observations = read_observations(observations_path) + if generator is None: + generator = DeterministicObservationBundleGenerator() + return generator.generate(observations, output_root, source_video_sha256) diff --git a/dimos/benchmark/spatiotemporal/runner.py b/dimos/benchmark/spatiotemporal/runner.py new file mode 100644 index 0000000000..8128a4140d --- /dev/null +++ b/dimos/benchmark/spatiotemporal/runner.py @@ -0,0 +1,123 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Candidate evaluation runner helpers.""" + +from collections.abc import Collection, Mapping, Sequence +import re + +from dimos.benchmark.spatiotemporal.models import ( + OracleAnswer, + Prediction, + PredictionStatus, + Question, + QuestionId, + QuestionKind, +) +from dimos.benchmark.spatiotemporal.scoring import ( + EvaluationReport, + QuestionDiagnostic, + QuestionPredicate, + summarize_statuses, +) + + +def parse_candidate_prediction( + question_id: QuestionId, + raw_answer: str | bool | None, + expected: bool, +) -> tuple[PredictionStatus, Prediction | None]: + """Parse one candidate answer and classify it against private truth.""" + if raw_answer is None: + return PredictionStatus.MISSING, None + if isinstance(raw_answer, bool): + answer = raw_answer + elif raw_answer == "yes": + answer = True + elif raw_answer == "no": + answer = False + else: + return PredictionStatus.INVALID, None + + prediction = Prediction(question_id=question_id, answer=answer) + status = PredictionStatus.CORRECT if answer is expected else PredictionStatus.INCORRECT + return status, prediction + + +def build_evaluation_report( + questions: Sequence[Question], + oracles: Mapping[QuestionId, OracleAnswer], + raw_answers: Mapping[QuestionId, str | bool | None], + *, + source_video_sha256: str, + diagnostic_statuses: Collection[PredictionStatus] = tuple(PredictionStatus), +) -> EvaluationReport: + """Join public questions with private truth and aggregate candidate outcomes.""" + if re.fullmatch(r"[0-9a-f]{64}", source_video_sha256) is None: + raise ValueError("source video SHA-256 must be 64 lowercase hexadecimal characters") + + question_ids = tuple(question.question_id for question in questions) + if len(set(question_ids)) != len(question_ids): + raise ValueError("question IDs must be unique") + if set(oracles) != set(question_ids): + raise ValueError("oracle answers must exactly match public questions") + if set(raw_answers).difference(question_ids): + raise ValueError("candidate answers contain unknown question IDs") + + selected_statuses = frozenset(diagnostic_statuses) + statuses: list[PredictionStatus] = [] + diagnostics: list[QuestionDiagnostic] = [] + statuses_by_family: dict[QuestionKind, list[PredictionStatus]] = {} + statuses_by_predicate: dict[QuestionPredicate, list[PredictionStatus]] = {} + + for question in questions: + oracle = oracles[question.question_id] + if oracle.question_id != question.question_id: + raise ValueError("oracle map keys and question IDs must match") + status, prediction = parse_candidate_prediction( + question.question_id, + raw_answers.get(question.question_id), + oracle.expected, + ) + statuses.append(status) + statuses_by_family.setdefault(question.question_kind, []).append(status) + statuses_by_predicate.setdefault(question.predicate, []).append(status) + if status in selected_statuses: + diagnostics.append( + QuestionDiagnostic( + question_id=question.question_id, + status=status, + question_kind=question.question_kind, + predicate=question.predicate, + expected=oracle.expected, + predicted=prediction.answer if prediction is not None else None, + evidence_frame_ids=oracle.evidence_frame_ids, + evidence_interval_ids=oracle.evidence_interval_ids, + ) + ) + + return EvaluationReport( + source_video_sha256=source_video_sha256, + overall=summarize_statuses(tuple(statuses)), + status_counts={status: statuses.count(status) for status in PredictionStatus}, + by_family={ + family: summarize_statuses(tuple(family_statuses)) + for family, family_statuses in statuses_by_family.items() + }, + by_predicate={ + predicate: summarize_statuses(tuple(predicate_statuses)) + for predicate, predicate_statuses in statuses_by_predicate.items() + }, + diagnostics=tuple(diagnostics), + ) diff --git a/dimos/benchmark/spatiotemporal/scoring.py b/dimos/benchmark/spatiotemporal/scoring.py new file mode 100644 index 0000000000..886dce6072 --- /dev/null +++ b/dimos/benchmark/spatiotemporal/scoring.py @@ -0,0 +1,91 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Exact scoring for typed spatiotemporal predictions.""" + +from collections.abc import Mapping +from dataclasses import dataclass + +from dimos.benchmark.spatiotemporal.models import ( + IntervalId, + OracleAnswer, + Prediction, + PredictionStatus, + Question, + QuestionId, + QuestionKind, + QuestionResult, + SpatialPredicate, + TemporalPredicate, +) + +QuestionPredicate = SpatialPredicate | TemporalPredicate + + +@dataclass(frozen=True, slots=True) +class ScoreSummary: + """Aggregate exact-match counts for one report slice.""" + + total: int + correct: int + accuracy: float + + +@dataclass(frozen=True, slots=True) +class QuestionDiagnostic: + """Evidence-linked diagnostic information for one public question.""" + + question_id: QuestionId + status: PredictionStatus + question_kind: QuestionKind + predicate: QuestionPredicate + expected: bool + predicted: bool | None + evidence_frame_ids: tuple[int, ...] + evidence_interval_ids: tuple[IntervalId, ...] + + +@dataclass(frozen=True, slots=True) +class EvaluationReport: + """Aggregate candidate scores linked to source identity and teacher evidence.""" + + source_video_sha256: str + overall: ScoreSummary + status_counts: Mapping[PredictionStatus, int] + by_family: Mapping[QuestionKind, ScoreSummary] + by_predicate: Mapping[QuestionPredicate, ScoreSummary] + diagnostics: tuple[QuestionDiagnostic, ...] + + +def summarize_statuses(statuses: tuple[PredictionStatus, ...]) -> ScoreSummary: + """Summarize exact candidate statuses, counting all outcomes in the denominator.""" + total = len(statuses) + correct = statuses.count(PredictionStatus.CORRECT) + return ScoreSummary(total=total, correct=correct, accuracy=correct / total if total else 0.0) + + +def score_prediction( + question: Question, + oracle: OracleAnswer, + prediction: Prediction, +) -> QuestionResult: + """Score one typed prediction against separately supplied private truth.""" + if len({question.question_id, oracle.question_id, prediction.question_id}) != 1: + raise ValueError("question IDs must match for scoring") + return QuestionResult( + question_id=question.question_id, + expected=oracle.expected, + predicted=prediction.answer, + correct=prediction.answer is oracle.expected, + ) diff --git a/dimos/benchmark/spatiotemporal/temporal_memory_answerer.py b/dimos/benchmark/spatiotemporal/temporal_memory_answerer.py new file mode 100644 index 0000000000..1669c0adaf --- /dev/null +++ b/dimos/benchmark/spatiotemporal/temporal_memory_answerer.py @@ -0,0 +1,66 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Public-only CandidateAnswerer adapter for TemporalMemory evaluation.""" + +from collections.abc import Callable +from pathlib import Path + +from dimos.benchmark.spatiotemporal.models import Question +from dimos.benchmark.spatiotemporal.ports import CandidateReadiness + + +class TemporalMemoryAnswerer: + """Adapt TemporalMemory ingestion, query, and cleanup callables for evaluation.""" + + def __init__( + self, + *, + ingest: Callable[[Path], int], + query: Callable[[str], str | bool | None], + cleanup: Callable[[], None], + ) -> None: + self._ingest = ingest + self._query = query + self._cleanup = cleanup + self._readiness: CandidateReadiness | None = None + self._closed = False + + def ingest_video(self, video_path: Path) -> CandidateReadiness: + """Ingest only the public source video and report candidate readiness.""" + self._require_open() + readiness = CandidateReadiness( + ready=(frame_count := self._ingest(video_path)) > 0, + ingested_frame_count=frame_count, + ) + self._readiness = readiness + return readiness + + def answer(self, question: Question) -> str | bool | None: + """Answer a public question only after successful video ingestion.""" + self._require_open() + if self._readiness is None or not self._readiness.ready: + raise RuntimeError("video must be ingested before answering questions") + return self._query(question.text) + + def close(self) -> None: + """Release TemporalMemory resources exactly once.""" + if self._closed: + return + self._closed = True + self._cleanup() + + def _require_open(self) -> None: + if self._closed: + raise RuntimeError("TemporalMemory answerer is closed") diff --git a/dimos/benchmark/spatiotemporal/test_bundles.py b/dimos/benchmark/spatiotemporal/test_bundles.py new file mode 100644 index 0000000000..f6bb99f840 --- /dev/null +++ b/dimos/benchmark/spatiotemporal/test_bundles.py @@ -0,0 +1,494 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for deterministic public/private evaluation bundles.""" + +from hashlib import sha256 +from pathlib import Path + +import pytest + +from dimos.benchmark.spatiotemporal.bundles import load_bundle, write_bundle +from dimos.benchmark.spatiotemporal.models import ( + BoundingBox2D, + ObjectObservation, + OracleAnswer, + OracleBundleManifest, + PublicBundleManifest, + Question, + QuestionKind, + RelationFact, + RelationInterval, + SpatialPredicate, + TemporalPredicate, +) +from dimos.benchmark.spatiotemporal.utilities import ( + SCHEMA_VERSION, + JsonValue, + canonical_model_json, + stable_id, +) + + +def _spatial_records() -> tuple[ + Question, + ObjectObservation, + RelationFact, + OracleAnswer, +]: + episode_id = "episode_1" + subject_id = "obj_red" + object_id = "obj_blue" + predicate = SpatialPredicate.LEFT_OF + relation_id = stable_id( + "relation", + { + "object_ids": (subject_id, object_id), + "predicate": predicate.value, + "schema_version": SCHEMA_VERSION, + }, + ) + question_id = stable_id( + "question", + { + "object_ids": (subject_id, object_id), + "predicate": predicate.value, + "question_kind": QuestionKind.SPATIAL.value, + "reference_ids": (), + "schema_version": SCHEMA_VERSION, + }, + ) + question = Question( + question_id=question_id, + episode_id=episode_id, + text="Is obj_red left of obj_blue?", + question_kind=QuestionKind.SPATIAL, + predicate=predicate, + object_ids=(subject_id, object_id), + ) + observation = ObjectObservation( + episode_id=episode_id, + frame_id=3, + timestamp_s=0.3, + object_id=subject_id, + label="red object", + box=BoundingBox2D(x_min=0.1, y_min=0.2, x_max=0.3, y_max=0.4), + confidence=0.9, + ) + fact = RelationFact( + relation_id=relation_id, + episode_id=episode_id, + frame_id=3, + timestamp_s=0.3, + subject_id=subject_id, + predicate=predicate, + object_id=object_id, + evidence_frame_ids=(3,), + ) + answer = OracleAnswer( + question_id=question_id, + expected=True, + evidence_frame_ids=(3,), + ) + return question, observation, fact, answer + + +def _interval_for_fact(fact: RelationFact) -> RelationInterval: + contract: dict[str, JsonValue] = { + "end_frame_id": fact.frame_id, + "end_timestamp_s": fact.timestamp_s, + "episode_id": fact.episode_id, + "object_id": fact.object_id, + "predicate": fact.predicate.value, + "relation_id": fact.relation_id, + "schema_version": SCHEMA_VERSION, + "start_frame_id": fact.frame_id, + "start_timestamp_s": fact.timestamp_s, + "subject_id": fact.subject_id, + } + return RelationInterval( + interval_id=stable_id("interval", contract), + relation_id=fact.relation_id, + episode_id=fact.episode_id, + subject_id=fact.subject_id, + predicate=fact.predicate, + object_id=fact.object_id, + start_frame_id=fact.frame_id, + end_frame_id=fact.frame_id, + start_timestamp_s=fact.timestamp_s, + end_timestamp_s=fact.timestamp_s, + evidence_frame_ids=(fact.frame_id,), + ) + + +def test_writes_and_loads_public_questions_without_private_teacher_data(tmp_path: Path) -> None: + question, observation, fact, answer = _spatial_records() + + write_bundle( + tmp_path, + episode_id="episode_1", + source_video_sha256="1" * 64, + questions=(question,), + observations=(observation,), + relation_facts=(fact,), + relation_intervals=(), + answers=(answer,), + ) + loaded = load_bundle(tmp_path) + + assert loaded.questions == (question,) + assert loaded.observations == (observation,) + assert loaded.relation_facts == (fact,) + assert loaded.relation_intervals == () + assert loaded.answers == (answer,) + public_text = "\n".join( + path.read_text(encoding="utf-8") for path in sorted((tmp_path / "public").iterdir()) + ) + assert "expected" not in public_text + assert "evidence_frame_ids" not in public_text + assert "confidence" not in public_text + assert (tmp_path / "oracle" / "answers.jsonl").is_file() + + +def test_bundle_output_is_byte_identical_across_roots(tmp_path: Path) -> None: + question, observation, fact, answer = _spatial_records() + roots = (tmp_path / "first", tmp_path / "second") + for root in roots: + write_bundle( + root, + episode_id="episode_1", + source_video_sha256="1" * 64, + questions=(question,), + observations=(observation,), + relation_facts=(fact,), + relation_intervals=(), + answers=(answer,), + ) + + outputs = [ + {path.relative_to(root): path.read_bytes() for path in root.rglob("*") if path.is_file()} + for root in roots + ] + assert outputs[0] == outputs[1] + + +def test_rejects_duplicate_public_question_references(tmp_path: Path) -> None: + question, observation, fact, answer = _spatial_records() + + with pytest.raises(ValueError, match="duplicate question ID"): + write_bundle( + tmp_path, + episode_id="episode_1", + source_video_sha256="1" * 64, + questions=(question, question), + observations=(observation,), + relation_facts=(fact,), + relation_intervals=(), + answers=(answer,), + ) + + +def test_rejects_oracle_answers_for_foreign_questions(tmp_path: Path) -> None: + question, observation, fact, _ = _spatial_records() + foreign_answer = OracleAnswer( + question_id=f"question_{'0' * 64}", + expected=True, + evidence_frame_ids=(3,), + ) + + with pytest.raises(ValueError, match="foreign question ID"): + write_bundle( + tmp_path, + episode_id="episode_1", + source_video_sha256="1" * 64, + questions=(question,), + observations=(observation,), + relation_facts=(fact,), + relation_intervals=(), + answers=(foreign_answer,), + ) + + +def test_rejects_missing_oracle_answer_references(tmp_path: Path) -> None: + question, observation, fact, _ = _spatial_records() + + with pytest.raises(ValueError, match="missing oracle answer"): + write_bundle( + tmp_path, + episode_id="episode_1", + source_video_sha256="1" * 64, + questions=(question,), + observations=(observation,), + relation_facts=(fact,), + relation_intervals=(), + answers=(), + ) + + +def test_rejects_duplicate_oracle_answer_references(tmp_path: Path) -> None: + question, observation, fact, answer = _spatial_records() + + with pytest.raises(ValueError, match="duplicate oracle answer"): + write_bundle( + tmp_path, + episode_id="episode_1", + source_video_sha256="1" * 64, + questions=(question,), + observations=(observation,), + relation_facts=(fact,), + relation_intervals=(), + answers=(answer, answer), + ) + + +def test_rejects_temporal_questions_with_foreign_relation_references(tmp_path: Path) -> None: + _, observation, fact, _ = _spatial_records() + references = (fact.relation_id, f"relation_{'0' * 64}") + question_id = stable_id( + "question", + { + "object_ids": (), + "predicate": TemporalPredicate.BEFORE.value, + "question_kind": QuestionKind.TEMPORAL.value, + "reference_ids": references, + "schema_version": SCHEMA_VERSION, + }, + ) + question = Question( + question_id=question_id, + episode_id="episode_1", + text="Did the first relation happen before the second?", + question_kind=QuestionKind.TEMPORAL, + predicate=TemporalPredicate.BEFORE, + object_ids=(), + reference_ids=references, + ) + answer = OracleAnswer(question_id=question_id, expected=True, evidence_frame_ids=(3,)) + + with pytest.raises(ValueError, match="foreign relation reference"): + write_bundle( + tmp_path, + episode_id="episode_1", + source_video_sha256="1" * 64, + questions=(question,), + observations=(observation,), + relation_facts=(fact,), + relation_intervals=(_interval_for_fact(fact),), + answers=(answer,), + ) + + +def test_rejects_answers_with_foreign_interval_evidence(tmp_path: Path) -> None: + question, observation, fact, _ = _spatial_records() + answer = OracleAnswer( + question_id=question.question_id, + expected=True, + evidence_interval_ids=(f"interval_{'0' * 64}",), + ) + + with pytest.raises(ValueError, match="foreign interval reference"): + write_bundle( + tmp_path, + episode_id="episode_1", + source_video_sha256="1" * 64, + questions=(question,), + observations=(observation,), + relation_facts=(fact,), + relation_intervals=(_interval_for_fact(fact),), + answers=(answer,), + ) + + +def test_rejects_records_from_a_foreign_episode(tmp_path: Path) -> None: + question, observation, fact, answer = _spatial_records() + foreign_question = question.model_copy(update={"episode_id": "episode_2"}) + + with pytest.raises(ValueError, match="foreign episode"): + write_bundle( + tmp_path, + episode_id="episode_1", + source_video_sha256="1" * 64, + questions=(foreign_question,), + observations=(observation,), + relation_facts=(fact,), + relation_intervals=(), + answers=(answer,), + ) + + +def test_loader_rejects_artifacts_that_do_not_match_the_manifest(tmp_path: Path) -> None: + question, observation, fact, answer = _spatial_records() + write_bundle( + tmp_path, + episode_id="episode_1", + source_video_sha256="1" * 64, + questions=(question,), + observations=(observation,), + relation_facts=(fact,), + relation_intervals=(), + answers=(answer,), + ) + questions_path = tmp_path / "public" / "questions.jsonl" + questions_path.write_bytes(questions_path.read_bytes() + b"\n") + + with pytest.raises(ValueError, match="artifact digest"): + load_bundle(tmp_path) + + +def test_loader_rejects_traversal_artifact_paths(tmp_path: Path) -> None: + question, observation, fact, answer = _spatial_records() + write_bundle( + tmp_path, + episode_id="episode_1", + source_video_sha256="1" * 64, + questions=(question,), + observations=(observation,), + relation_facts=(fact,), + relation_intervals=(), + answers=(answer,), + ) + public_path = tmp_path / "public" / "manifest.json" + public = PublicBundleManifest.model_validate_json(public_path.read_bytes()) + public = public.model_copy( + update={"questions": public.questions.model_copy(update={"path": "../questions.jsonl"})} + ) + public_bytes = f"{canonical_model_json(public)}\n".encode() + public_path.write_bytes(public_bytes) + oracle_path = tmp_path / "oracle" / "manifest.json" + oracle = OracleBundleManifest.model_validate_json(oracle_path.read_bytes()).model_copy( + update={"public_manifest_sha256": sha256(public_bytes).hexdigest()} + ) + oracle_path.write_text(f"{canonical_model_json(oracle)}\n", encoding="utf-8") + + with pytest.raises(ValueError, match="relative canonical path"): + load_bundle(tmp_path) + + +def test_loader_rejects_symlinked_artifacts(tmp_path: Path) -> None: + question, observation, fact, answer = _spatial_records() + bundle_root = tmp_path / "bundle" + write_bundle( + bundle_root, + episode_id="episode_1", + source_video_sha256="1" * 64, + questions=(question,), + observations=(observation,), + relation_facts=(fact,), + relation_intervals=(), + answers=(answer,), + ) + questions_path = bundle_root / "public" / "questions.jsonl" + external_questions_path = tmp_path / "questions.jsonl" + questions_path.replace(external_questions_path) + questions_path.symlink_to(external_questions_path) + + with pytest.raises(ValueError, match="symlink"): + load_bundle(bundle_root) + + +def test_loader_rejects_symlinked_manifests(tmp_path: Path) -> None: + question, observation, fact, answer = _spatial_records() + bundle_root = tmp_path / "bundle" + write_bundle( + bundle_root, + episode_id="episode_1", + source_video_sha256="1" * 64, + questions=(question,), + observations=(observation,), + relation_facts=(fact,), + relation_intervals=(), + answers=(answer,), + ) + manifest_path = bundle_root / "oracle" / "manifest.json" + external_manifest_path = tmp_path / "manifest.json" + manifest_path.replace(external_manifest_path) + manifest_path.symlink_to(external_manifest_path) + + with pytest.raises(ValueError, match="symlink"): + load_bundle(bundle_root) + + +def test_writer_rejects_symlinked_output_directories(tmp_path: Path) -> None: + question, observation, fact, answer = _spatial_records() + bundle_root = tmp_path / "bundle" + bundle_root.mkdir() + external_root = tmp_path / "external" + external_root.mkdir() + (bundle_root / "public").symlink_to(external_root, target_is_directory=True) + + with pytest.raises(ValueError, match="symlink"): + write_bundle( + bundle_root, + episode_id="episode_1", + source_video_sha256="1" * 64, + questions=(question,), + observations=(observation,), + relation_facts=(fact,), + relation_intervals=(), + answers=(answer,), + ) + + assert not tuple(external_root.iterdir()) + + +def test_rejects_duplicate_interval_identities(tmp_path: Path) -> None: + question, observation, fact, answer = _spatial_records() + interval = _interval_for_fact(fact) + + with pytest.raises(ValueError, match="duplicate interval ID"): + write_bundle( + tmp_path, + episode_id="episode_1", + source_video_sha256="1" * 64, + questions=(question,), + observations=(observation,), + relation_facts=(fact,), + relation_intervals=(interval, interval), + answers=(answer,), + ) + + +def test_loader_rejects_malformed_episode_metadata_with_valid_digests(tmp_path: Path) -> None: + question, observation, fact, answer = _spatial_records() + write_bundle( + tmp_path, + episode_id="episode_1", + source_video_sha256="1" * 64, + questions=(question,), + observations=(observation,), + relation_facts=(fact,), + relation_intervals=(), + answers=(answer,), + ) + episode_bytes = b"{}\n" + (tmp_path / "public" / "episode.json").write_bytes(episode_bytes) + public_path = tmp_path / "public" / "manifest.json" + public = PublicBundleManifest.model_validate_json(public_path.read_bytes()) + public = public.model_copy( + update={ + "episode": public.episode.model_copy( + update={"sha256": sha256(episode_bytes).hexdigest()} + ) + } + ) + public_bytes = f"{canonical_model_json(public)}\n".encode() + public_path.write_bytes(public_bytes) + oracle_path = tmp_path / "oracle" / "manifest.json" + oracle = OracleBundleManifest.model_validate_json(oracle_path.read_bytes()).model_copy( + update={"public_manifest_sha256": sha256(public_bytes).hexdigest()} + ) + oracle_path.write_text(f"{canonical_model_json(oracle)}\n", encoding="utf-8") + + with pytest.raises(ValueError, match="episode metadata"): + load_bundle(tmp_path) diff --git a/dimos/benchmark/spatiotemporal/test_demo.py b/dimos/benchmark/spatiotemporal/test_demo.py new file mode 100644 index 0000000000..5f756eac47 --- /dev/null +++ b/dimos/benchmark/spatiotemporal/test_demo.py @@ -0,0 +1,218 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the one-command spatiotemporal video-QA demonstration.""" + +import inspect +from pathlib import Path +from types import SimpleNamespace + +import cv2 +import numpy as np +import pytest + +from dimos.benchmark.spatiotemporal import demo +from dimos.benchmark.spatiotemporal.demo import ( + TimestampScriptedPublicVlModel, + _run_temporal_memory_candidate, + _trim_video, + run_demo, +) +from dimos.msgs.sensor_msgs.Image import Image + + +def test_scripted_candidate_answer_depends_only_on_public_question() -> None: + model = TimestampScriptedPublicVlModel() + image = Image.from_numpy(np.zeros((2, 2, 3), dtype=np.uint8)) + question = "Did relation_a happen before relation_b?" + first = model.query( + image, + f'**Question:** {question}\n**Context:** {{"timestamp": 1}}', + ) + second = model.query( + image, + f'**Question:** {question}\n**Context:** {{"timestamp": 999}}', + ) + + assert first == second + assert first in {"yes", "no"} + + +@pytest.mark.parametrize("duration_s", [14, 31]) +def test_demo_rejects_video_outside_constrained_duration(duration_s: int) -> None: + with pytest.raises(ValueError, match="between 15 and 30 seconds"): + run_demo( + Path("not-opened.mp4"), + Path("not-created"), + duration_s=duration_s, + ) + + +def test_candidate_boundary_accepts_no_private_bundle() -> None: + assert tuple(inspect.signature(_run_temporal_memory_candidate).parameters) == ( + "video", + "questions", + "output_root", + "duration_s", + ) + + +def test_demo_uses_ephemeral_candidate_root_outside_teacher_output( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = tmp_path / "source.mp4" + source.write_bytes(b"video") + output_root = tmp_path / "teacher-output" + bundle = SimpleNamespace(questions=()) + observed: dict[str, Path] = {} + + def trim(_source: Path, target: Path, _duration_s: float) -> tuple[float, int]: + target.write_bytes(b"trimmed video") + return 1.0, 25 + + monkeypatch.setattr(demo, "_trim_video", trim) + monkeypatch.setattr(demo, "_teacher_run", lambda *_: (bundle, {})) + + def candidate( + video: Path, + questions: tuple[()], + candidate_root: Path, + duration_s: int, + ) -> tuple[dict, dict]: + del questions, duration_s + observed["root"] = candidate_root + observed["video"] = video + assert not candidate_root.is_relative_to(output_root.resolve()) + assert video.parent == candidate_root + assert not video.is_relative_to(output_root.resolve()) + assert video.read_bytes() == b"trimmed video" + assert not (candidate_root / "bundle-a").exists() + return {}, {} + + monkeypatch.setattr(demo, "_run_temporal_memory_candidate", candidate) + monkeypatch.setattr(demo, "_score_candidate", lambda *_: {}) + + run_demo(source, output_root) + + assert not observed["root"].exists() + assert not observed["video"].exists() + + +def test_invalid_stride_has_no_output_side_effect(tmp_path: Path) -> None: + output_root = tmp_path / "output" + with pytest.raises(ValueError, match="stride must be positive"): + run_demo(Path("not-opened.mp4"), output_root, frame_stride=0) + assert not output_root.exists() + + +def test_demo_rejects_symlinked_output_root(tmp_path: Path) -> None: + source = tmp_path / "source.mp4" + source.write_bytes(b"not decoded") + actual_output = tmp_path / "actual" + actual_output.mkdir() + linked_output = tmp_path / "linked" + linked_output.symlink_to(actual_output, target_is_directory=True) + + with pytest.raises(ValueError, match="contains a symlink"): + run_demo(source, linked_output) + + +def test_demo_rejects_source_target_alias(tmp_path: Path) -> None: + output_root = tmp_path / "output" + output_root.mkdir() + source = output_root / "office_robot_25s.mp4" + source.write_bytes(b"not decoded") + + with pytest.raises(ValueError, match="paths must differ"): + run_demo(source, output_root) + + +def test_demo_rejects_symlinked_summary_artifact(tmp_path: Path) -> None: + source = tmp_path / "source.mp4" + source.write_bytes(b"not decoded") + output_root = tmp_path / "output" + output_root.mkdir() + outside = tmp_path / "outside.json" + (output_root / "summary.json").symlink_to(outside) + + with pytest.raises(ValueError, match="contains a symlink"): + run_demo(source, output_root) + assert not outside.exists() + + +def test_trim_rejects_hard_link_source_target_alias(tmp_path: Path) -> None: + source = tmp_path / "source.mp4" + source.write_bytes(b"must remain unchanged") + target = tmp_path / "target.mp4" + target.hardlink_to(source) + + with pytest.raises(ValueError, match="paths must differ"): + _trim_video(source, target, 15.0) + assert source.read_bytes() == b"must remain unchanged" + + +def test_trim_rejects_silent_video_writer_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = tmp_path / "source.mp4" + source.write_bytes(b"source placeholder") + target = tmp_path / "target.mp4" + + class SourceCapture: + def __init__(self) -> None: + self.reads = 0 + + def isOpened(self) -> bool: + return True + + def get(self, prop: int) -> float: + return { + cv2.CAP_PROP_FPS: 1.0, + cv2.CAP_PROP_FRAME_WIDTH: 2.0, + cv2.CAP_PROP_FRAME_HEIGHT: 2.0, + }.get(prop, 0.0) + + def read(self) -> tuple[bool, np.ndarray | None]: + self.reads += 1 + return True, np.zeros((2, 2, 3), dtype=np.uint8) + + def release(self) -> None: + return None + + class VerificationCapture: + def isOpened(self) -> bool: + return True + + def read(self) -> tuple[bool, None]: + return False, None + + def release(self) -> None: + return None + + class SilentWriter: + def isOpened(self) -> bool: + return True + + def write(self, frame: np.ndarray) -> None: + return None + + def release(self) -> None: + return None + + captures = iter((SourceCapture(), VerificationCapture())) + monkeypatch.setattr(cv2, "VideoCapture", lambda _: next(captures)) + monkeypatch.setattr(cv2, "VideoWriter", lambda *args: SilentWriter()) + + with pytest.raises(RuntimeError, match="0 decodable frames"): + _trim_video(source, target, 15.0) diff --git a/dimos/benchmark/spatiotemporal/test_generation.py b/dimos/benchmark/spatiotemporal/test_generation.py new file mode 100644 index 0000000000..d1b9ef8d1c --- /dev/null +++ b/dimos/benchmark/spatiotemporal/test_generation.py @@ -0,0 +1,213 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Behavioral tests for dataset question generation.""" + +from collections import Counter + +import pytest + +from dimos.benchmark.spatiotemporal.generation import ( + generate_spatial_questions, + generate_temporal_question_cases, +) +from dimos.benchmark.spatiotemporal.models import ( + RelationFact, + RelationInterval, + SpatialPredicate, + TemporalPredicate, +) +from dimos.benchmark.spatiotemporal.utilities import SCHEMA_VERSION, JsonValue, stable_id + + +def _interval( + subject_id: str, + predicate: SpatialPredicate, + object_id: str, + start_frame_id: int, + end_frame_id: int, + start_timestamp_s: float | None = None, + end_timestamp_s: float | None = None, +) -> RelationInterval: + if start_timestamp_s is None: + start_timestamp_s = start_frame_id / 10 + if end_timestamp_s is None: + end_timestamp_s = end_frame_id / 10 + relation_id = stable_id( + "relation", + { + "object_ids": (subject_id, object_id), + "predicate": predicate.value, + "schema_version": SCHEMA_VERSION, + }, + ) + contract: dict[str, JsonValue] = { + "end_frame_id": end_frame_id, + "end_timestamp_s": end_timestamp_s, + "episode_id": "episode_1", + "object_id": object_id, + "predicate": predicate.value, + "relation_id": relation_id, + "schema_version": SCHEMA_VERSION, + "start_frame_id": start_frame_id, + "start_timestamp_s": start_timestamp_s, + "subject_id": subject_id, + } + return RelationInterval( + interval_id=stable_id("interval", contract), + relation_id=relation_id, + episode_id="episode_1", + subject_id=subject_id, + predicate=predicate, + object_id=object_id, + start_frame_id=start_frame_id, + end_frame_id=end_frame_id, + start_timestamp_s=start_timestamp_s, + end_timestamp_s=end_timestamp_s, + evidence_frame_ids=tuple(range(start_frame_id, end_frame_id + 1)), + ) + + +def test_generates_one_public_spatial_question_per_accepted_relation() -> None: + relation_id = stable_id( + "relation", + { + "object_ids": ("obj_red", "obj_blue"), + "predicate": SpatialPredicate.LEFT_OF.value, + "schema_version": SCHEMA_VERSION, + }, + ) + facts = tuple( + RelationFact( + relation_id=relation_id, + episode_id="episode_1", + frame_id=frame_id, + timestamp_s=timestamp_s, + subject_id="obj_red", + predicate=SpatialPredicate.LEFT_OF, + object_id="obj_blue", + evidence_frame_ids=(frame_id,), + ) + for frame_id, timestamp_s in ((12, 0.5), (13, 0.6)) + ) + + questions = generate_spatial_questions(facts) + + assert len(questions) == 1 + question = questions[0] + assert ( + question.question_id + == "question_4ef1579875e841ec7602de2e9cfc95f0bfdfb5e8d2e38ca798f462f8f72eda1e" + ) + assert question.text == "Is obj_red left of obj_blue?" + assert question.object_ids == ("obj_red", "obj_blue") + public_record = question.model_dump(mode="json") + assert ( + not { + "relation_id", + "frame_id", + "timestamp_s", + "evidence_frame_ids", + "expected", + } + & public_record.keys() + ) + + +def test_rejects_facts_from_multiple_episodes() -> None: + relation_id = stable_id( + "relation", + { + "object_ids": ("obj_red", "obj_blue"), + "predicate": SpatialPredicate.LEFT_OF.value, + "schema_version": SCHEMA_VERSION, + }, + ) + facts = tuple( + RelationFact( + relation_id=relation_id, + episode_id=episode_id, + frame_id=frame_id, + timestamp_s=timestamp_s, + subject_id="obj_red", + predicate=SpatialPredicate.LEFT_OF, + object_id="obj_blue", + evidence_frame_ids=(frame_id,), + ) + for episode_id, frame_id, timestamp_s in ( + ("episode_1", 12, 0.5), + ("episode_2", 13, 0.6), + ) + ) + + with pytest.raises(ValueError, match="one episode"): + generate_spatial_questions(facts) + + +def test_generates_balanced_temporal_questions_in_byte_stable_order() -> None: + first = _interval("obj_red", SpatialPredicate.LEFT_OF, "obj_blue", 1, 2) + second = _interval("obj_green", SpatialPredicate.ABOVE, "obj_yellow", 4, 5) + + cases = generate_temporal_question_cases((second, first)) + + assert tuple( + (question.model_dump_json(), answer.model_dump_json()) for question, answer in cases + ) == tuple( + (question.model_dump_json(), answer.model_dump_json()) + for question, answer in generate_temporal_question_cases((first, second)) + ) + assert {(question.predicate, question.reference_ids) for question, _ in cases} == { + (TemporalPredicate.BEFORE, (first.relation_id, second.relation_id)), + (TemporalPredicate.AFTER, (second.relation_id, first.relation_id)), + (TemporalPredicate.AFTER, (first.relation_id, second.relation_id)), + (TemporalPredicate.BEFORE, (second.relation_id, first.relation_id)), + } + assert Counter((question.predicate, answer.expected) for question, answer in cases) == { + (TemporalPredicate.BEFORE, True): 1, + (TemporalPredicate.BEFORE, False): 1, + (TemporalPredicate.AFTER, True): 1, + (TemporalPredicate.AFTER, False): 1, + } + + +def test_omits_temporal_questions_with_bidirectional_interval_proofs() -> None: + relation_a_early = _interval("obj_red", SpatialPredicate.LEFT_OF, "obj_blue", 1, 2) + relation_b_early = _interval("obj_green", SpatialPredicate.ABOVE, "obj_yellow", 4, 5) + relation_b_late = _interval("obj_green", SpatialPredicate.ABOVE, "obj_yellow", 7, 8) + relation_a_late = _interval("obj_red", SpatialPredicate.LEFT_OF, "obj_blue", 10, 11) + + assert ( + generate_temporal_question_cases( + (relation_a_late, relation_b_early, relation_a_early, relation_b_late) + ) + == () + ) + + +def test_requires_strict_frame_and_timestamp_interval_order() -> None: + first = _interval("obj_red", SpatialPredicate.LEFT_OF, "obj_blue", 1, 2) + touching = _interval("obj_green", SpatialPredicate.ABOVE, "obj_yellow", 2, 4) + overlapping = _interval("obj_green", SpatialPredicate.ABOVE, "obj_yellow", 1, 4) + disagreeing = _interval( + "obj_green", + SpatialPredicate.ABOVE, + "obj_yellow", + 4, + 5, + start_timestamp_s=-0.2, + end_timestamp_s=-0.1, + ) + + for unproven_second in (touching, overlapping, disagreeing): + assert generate_temporal_question_cases((first, unproven_second)) == () diff --git a/dimos/benchmark/spatiotemporal/test_intervals.py b/dimos/benchmark/spatiotemporal/test_intervals.py new file mode 100644 index 0000000000..31c8bebb54 --- /dev/null +++ b/dimos/benchmark/spatiotemporal/test_intervals.py @@ -0,0 +1,245 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for deterministic relation interval construction.""" + +import pytest + +from dimos.benchmark.spatiotemporal.models import ( + RelationFact, + RelationInterval, + SpatialPredicate, + TemporalPredicate, +) +from dimos.benchmark.spatiotemporal.utilities import SCHEMA_VERSION, stable_id + + +def _relation_id(subject_id: str, predicate: SpatialPredicate, object_id: str) -> str: + return stable_id( + "relation", + { + "object_ids": (subject_id, object_id), + "predicate": predicate.value, + "schema_version": SCHEMA_VERSION, + }, + ) + + +def _fact( + frame_id: int, + timestamp_s: float, + *, + episode_id: str = "episode_1", + subject_id: str = "mug_1", + predicate: SpatialPredicate = SpatialPredicate.LEFT_OF, + object_id: str = "laptop_1", +) -> RelationFact: + return RelationFact( + relation_id=_relation_id(subject_id, predicate, object_id), + episode_id=episode_id, + frame_id=frame_id, + timestamp_s=timestamp_s, + subject_id=subject_id, + predicate=predicate, + object_id=object_id, + evidence_frame_ids=(frame_id,), + ) + + +def test_coalesces_only_consecutive_samples_with_stable_relation_identity() -> None: + from dimos.benchmark.spatiotemporal.intervals import build_relation_intervals + + intervals = build_relation_intervals( + ( + _fact(1, 0.1), + _fact(2, 0.2), + _fact( + 2, + 0.2, + subject_id="lamp_1", + predicate=SpatialPredicate.ABOVE, + object_id="table_1", + ), + _fact(4, 0.4), + _fact(1, 0.1, episode_id="episode_2"), + ) + ) + + assert [ + ( + interval.episode_id, + interval.subject_id, + interval.predicate, + interval.object_id, + interval.start_frame_id, + interval.end_frame_id, + interval.evidence_frame_ids, + ) + for interval in intervals + ] == [ + ( + "episode_1", + "lamp_1", + SpatialPredicate.ABOVE, + "table_1", + 2, + 2, + (2,), + ), + ( + "episode_1", + "mug_1", + SpatialPredicate.LEFT_OF, + "laptop_1", + 1, + 2, + (1, 2), + ), + ( + "episode_1", + "mug_1", + SpatialPredicate.LEFT_OF, + "laptop_1", + 4, + 4, + (4,), + ), + ( + "episode_2", + "mug_1", + SpatialPredicate.LEFT_OF, + "laptop_1", + 1, + 1, + (1,), + ), + ] + + +@pytest.mark.parametrize( + "facts", + [ + ( + _fact(1, 0.1), + _fact( + 1, + 0.2, + subject_id="lamp_1", + predicate=SpatialPredicate.ABOVE, + object_id="table_1", + ), + ), + (_fact(1, 0.1), _fact(2, 0.1)), + (_fact(1, 0.2), _fact(2, 0.1)), + ], +) +def test_rejects_conflicting_episode_sample_schedules( + facts: tuple[RelationFact, ...], +) -> None: + from dimos.benchmark.spatiotemporal.intervals import build_relation_intervals + + with pytest.raises(ValueError, match="conflicting sample schedule"): + build_relation_intervals(facts) + + +def test_derives_only_unanimous_strict_temporal_ordering() -> None: + from dimos.benchmark.spatiotemporal.intervals import ( + build_relation_intervals, + derive_temporal_predicate, + ) + + earlier = _relation_id("mug_1", SpatialPredicate.LEFT_OF, "laptop_1") + later = _relation_id("lamp_1", SpatialPredicate.ABOVE, "table_1") + + def paired_intervals( + first_frames: tuple[int, ...], second_frames: tuple[int, ...] + ) -> tuple[RelationInterval, ...]: + return build_relation_intervals( + tuple(_fact(frame, frame / 10) for frame in first_frames) + + tuple( + _fact( + frame, + frame / 10, + subject_id="lamp_1", + predicate=SpatialPredicate.ABOVE, + object_id="table_1", + ) + for frame in second_frames + ) + ) + + ordered_facts = ( + _fact(1, 0.1), + _fact(2, 0.2), + _fact( + 4, + 0.4, + subject_id="lamp_1", + predicate=SpatialPredicate.ABOVE, + object_id="table_1", + ), + _fact( + 5, + 0.5, + subject_id="lamp_1", + predicate=SpatialPredicate.ABOVE, + object_id="table_1", + ), + ) + ordered = build_relation_intervals(ordered_facts) + + assert derive_temporal_predicate(earlier, later, ordered) is TemporalPredicate.BEFORE + assert derive_temporal_predicate(later, earlier, ordered) is TemporalPredicate.AFTER + assert ( + derive_temporal_predicate(earlier, later, tuple(reversed(ordered))) + is TemporalPredicate.BEFORE + ) + + touching = paired_intervals((1, 2), (2, 3)) + overlapping = paired_intervals((1, 2, 3), (2, 3, 4)) + containing = paired_intervals((1, 2, 3, 4, 5), (2, 3, 4)) + contradictory = build_relation_intervals( + ( + *ordered_facts, + _fact(4, 0.4, episode_id="episode_2"), + _fact( + 1, + 0.1, + episode_id="episode_2", + subject_id="lamp_1", + predicate=SpatialPredicate.ABOVE, + object_id="table_1", + ), + ) + ) + coordinate_contradiction = build_relation_intervals((_fact(1, 0.4),)) + ( + build_relation_intervals( + ( + _fact( + 4, + 0.1, + subject_id="lamp_1", + predicate=SpatialPredicate.ABOVE, + object_id="table_1", + ), + ) + ) + ) + + assert derive_temporal_predicate(earlier, later, touching) is None + assert derive_temporal_predicate(earlier, later, overlapping) is None + assert derive_temporal_predicate(earlier, later, containing) is None + assert derive_temporal_predicate(earlier, "relation_" + "0" * 64, ordered) is None + assert derive_temporal_predicate(earlier, later, contradictory) is None + assert derive_temporal_predicate(earlier, later, coordinate_contradiction) is None diff --git a/dimos/benchmark/spatiotemporal/test_models.py b/dimos/benchmark/spatiotemporal/test_models.py new file mode 100644 index 0000000000..220ee49065 --- /dev/null +++ b/dimos/benchmark/spatiotemporal/test_models.py @@ -0,0 +1,326 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for strict spatiotemporal QA contracts.""" + +from pydantic import ValidationError +import pytest + +from dimos.benchmark.spatiotemporal.models import ( + BoundingBox2D, + BundleArtifact, + ObjectObservation, + OracleAnswer, + OracleBundleManifest, + PredictionStatus, + PublicBundleManifest, + Question, + QuestionKind, + RelationFact, + RelationInterval, + SpatialPredicate, + TemporalPredicate, +) +from dimos.benchmark.spatiotemporal.utilities import SCHEMA_VERSION, stable_id + + +def test_bounding_box_accepts_only_strict_normalized_valid_bounds() -> None: + box = BoundingBox2D(x_min=0.1, y_min=0.2, x_max=0.4, y_max=0.8) + + assert box.model_dump() == { + "x_min": 0.1, + "y_min": 0.2, + "x_max": 0.4, + "y_max": 0.8, + } + + invalid_payloads = ( + {"x_min": 0.4, "y_min": 0.2, "x_max": 0.4, "y_max": 0.8}, + {"x_min": 0.5, "y_min": 0.2, "x_max": 0.4, "y_max": 0.8}, + {"x_min": 0.1, "y_min": 0.8, "x_max": 0.4, "y_max": 0.8}, + {"x_min": -0.1, "y_min": 0.2, "x_max": 0.4, "y_max": 0.8}, + {"x_min": 0.1, "y_min": 0.2, "x_max": 1.1, "y_max": 0.8}, + {"x_min": float("nan"), "y_min": 0.2, "x_max": 0.4, "y_max": 0.8}, + {"x_min": "0.1", "y_min": 0.2, "x_max": 0.4, "y_max": 0.8}, + ) + for payload in invalid_payloads: + with pytest.raises(ValidationError): + BoundingBox2D.model_validate(payload) + + with pytest.raises(ValidationError): + BoundingBox2D.model_validate( + { + "x_min": 0.1, + "y_min": 0.2, + "x_max": 0.4, + "y_max": 0.8, + "unexpected": True, + } + ) + with pytest.raises(ValidationError): + box.x_min = 0.0 + + +def test_object_observation_rejects_invalid_identity_time_and_confidence() -> None: + observation = ObjectObservation( + episode_id="episode_1", + frame_id=12, + timestamp_s=0.5, + object_id="mug_1", + label="mug", + box=BoundingBox2D(x_min=0.1, y_min=0.2, x_max=0.3, y_max=0.4), + confidence=0.9, + ) + + assert observation.object_id == "mug_1" + + invalid_overrides = ( + {"episode_id": ""}, + {"frame_id": -1}, + {"timestamp_s": float("inf")}, + {"object_id": ""}, + {"object_id": "mu\u0301g"}, + {"label": ""}, + {"confidence": -0.1}, + {"confidence": 1.1}, + ) + payload = observation.model_dump() + for override in invalid_overrides: + with pytest.raises(ValidationError): + ObjectObservation.model_validate(payload | override) + + +def test_object_observation_allows_finite_negative_timestamp_translation() -> None: + observation = ObjectObservation( + episode_id="episode_1", + frame_id=12, + timestamp_s=-0.5, + object_id="mug_1", + label="mug", + box=BoundingBox2D(x_min=0.1, y_min=0.2, x_max=0.3, y_max=0.4), + confidence=0.9, + ) + + assert observation.timestamp_s == -0.5 + + +def test_question_rejects_malformed_id_and_non_nfc_object_identity() -> None: + payload = { + "question_id": "question_4ef1579875e841ec7602de2e9cfc95f0bfdfb5e8d2e38ca798f462f8f72eda1e", + "episode_id": "episode_1", + "text": "Is the mug left of the laptop at the end?", + "question_kind": QuestionKind.SPATIAL, + "predicate": SpatialPredicate.LEFT_OF, + "object_ids": ("obj_red", "obj_blue"), + } + + assert Question.model_validate(payload).object_ids == ("obj_red", "obj_blue") + with pytest.raises(ValidationError): + Question.model_validate(payload | {"question_id": "question_1"}) + with pytest.raises(ValidationError): + Question.model_validate(payload | {"question_id": f"question_{'0' * 64}"}) + with pytest.raises(ValidationError): + Question.model_validate(payload | {"object_ids": ("mu\u0301g_1", "laptop_1")}) + with pytest.raises(ValidationError): + Question.model_validate(payload | {"object_ids": ("\ud800", "laptop_1")}) + with pytest.raises(ValidationError): + Question.model_validate(payload | {"object_ids": ("obj_red", "obj_red")}) + + +def test_relation_fact_has_stable_private_sample_identity() -> None: + contract = { + "object_ids": ("obj_red", "obj_blue"), + "predicate": SpatialPredicate.LEFT_OF.value, + "schema_version": SCHEMA_VERSION, + } + fact = RelationFact( + relation_id=stable_id("relation", contract), + episode_id="episode_1", + frame_id=12, + timestamp_s=0.5, + subject_id="obj_red", + predicate=SpatialPredicate.LEFT_OF, + object_id="obj_blue", + evidence_frame_ids=(12,), + ) + + assert ( + fact.relation_id + == "relation_50c38f99a34ced799c3b8c8bd3417ac288a6ce9481dc9de19525042a2e4f5ea3" + ) + with pytest.raises(ValidationError, match="relation ID"): + RelationFact.model_validate(fact.model_dump() | {"relation_id": f"relation_{'0' * 64}"}) + with pytest.raises(ValidationError, match="must differ"): + RelationFact.model_validate(fact.model_dump() | {"object_id": "obj_red"}) + with pytest.raises(ValidationError, match="must cite exactly"): + RelationFact.model_validate(fact.model_dump() | {"evidence_frame_ids": (11, 12)}) + with pytest.raises(ValidationError, match="ordered and unique"): + RelationFact.model_validate(fact.model_dump() | {"evidence_frame_ids": (12, 12)}) + + +def test_relation_interval_has_strict_consistent_bounds() -> None: + relation_id = stable_id( + "relation", + { + "object_ids": ("obj_red", "obj_blue"), + "predicate": SpatialPredicate.LEFT_OF.value, + "schema_version": SCHEMA_VERSION, + }, + ) + contract = { + "end_frame_id": 14, + "end_timestamp_s": 0.7, + "episode_id": "episode_1", + "object_id": "obj_blue", + "predicate": SpatialPredicate.LEFT_OF.value, + "relation_id": relation_id, + "schema_version": SCHEMA_VERSION, + "start_frame_id": 12, + "start_timestamp_s": 0.5, + "subject_id": "obj_red", + } + interval = RelationInterval( + interval_id=stable_id("interval", contract), + relation_id=relation_id, + episode_id="episode_1", + subject_id="obj_red", + predicate=SpatialPredicate.LEFT_OF, + object_id="obj_blue", + start_frame_id=12, + end_frame_id=14, + start_timestamp_s=0.5, + end_timestamp_s=0.7, + evidence_frame_ids=(12, 13, 14), + ) + + assert interval.start_timestamp_s < interval.end_timestamp_s + for end_frame_id, end_timestamp_s in ((12, 0.7), (14, 0.5)): + contradictory_contract = contract | { + "end_frame_id": end_frame_id, + "end_timestamp_s": end_timestamp_s, + } + contradictory_payload = interval.model_dump() | { + "end_frame_id": end_frame_id, + "end_timestamp_s": end_timestamp_s, + "interval_id": stable_id("interval", contradictory_contract), + "evidence_frame_ids": (12,) if end_frame_id == 12 else (12, 13, 14), + } + with pytest.raises(ValidationError, match="frame and timestamp bounds"): + RelationInterval.model_validate(contradictory_payload) + + for override in ( + {"end_frame_id": 11}, + {"end_timestamp_s": 0.4}, + {"evidence_frame_ids": (12, 14, 13)}, + {"interval_id": f"interval_{'0' * 64}"}, + ): + with pytest.raises(ValidationError): + RelationInterval.model_validate(interval.model_dump() | override) + + +def test_question_variants_are_disjoint_and_have_stable_ids() -> None: + references = (f"relation_{'1' * 64}", f"relation_{'2' * 64}") + question_id = stable_id( + "question", + { + "object_ids": (), + "predicate": TemporalPredicate.BEFORE.value, + "question_kind": QuestionKind.TEMPORAL.value, + "reference_ids": references, + "schema_version": SCHEMA_VERSION, + }, + ) + temporal = Question( + question_id=question_id, + episode_id="episode_1", + text="Did relation one happen before relation two?", + question_kind=QuestionKind.TEMPORAL, + predicate=TemporalPredicate.BEFORE, + object_ids=(), + reference_ids=references, + ) + + assert temporal.predicate is TemporalPredicate.BEFORE + invalid_variants = ( + {"question_kind": QuestionKind.SPATIAL}, + {"predicate": SpatialPredicate.LEFT_OF}, + {"object_ids": ("obj_red", "obj_blue")}, + {"reference_ids": (references[0], references[0])}, + {"reference_ids": ("relation_not-a-digest", references[1])}, + ) + for override in invalid_variants: + with pytest.raises(ValidationError): + Question.model_validate(temporal.model_dump() | override) + + +def test_oracle_evidence_and_prediction_status_contracts_are_private_and_closed() -> None: + question_id = f"question_{'1' * 64}" + oracle = OracleAnswer( + question_id=question_id, + expected=True, + evidence_frame_ids=(12,), + evidence_interval_ids=(f"interval_{'2' * 64}",), + ) + + assert oracle.evidence_interval_ids == (f"interval_{'2' * 64}",) + assert {status.value for status in PredictionStatus} == { + "correct", + "incorrect", + "missing", + "invalid", + } + + +def test_public_and_oracle_manifests_share_only_release_identity() -> None: + source_sha256 = "2" * 64 + bundle_id = stable_id( + "bundle", + { + "episode_id": "episode_1", + "schema_version": SCHEMA_VERSION, + "source_video_sha256": source_sha256, + }, + ) + public = PublicBundleManifest( + schema_version=SCHEMA_VERSION, + bundle_id=bundle_id, + episode_id="episode_1", + source_video_sha256=source_sha256, + episode=BundleArtifact(path="public/episode.json", sha256="3" * 64, record_count=1), + questions=BundleArtifact(path="public/questions.jsonl", sha256="4" * 64, record_count=12), + ) + oracle = OracleBundleManifest( + schema_version=SCHEMA_VERSION, + bundle_id=public.bundle_id, + episode_id=public.episode_id, + source_video_sha256=source_sha256, + public_manifest_sha256="8" * 64, + observations=BundleArtifact( + path="oracle/observations.jsonl", sha256="5" * 64, record_count=30 + ), + relation_intervals=BundleArtifact( + path="oracle/relation_intervals.jsonl", sha256="6" * 64, record_count=8 + ), + relation_facts=BundleArtifact( + path="oracle/relation_facts.jsonl", sha256="9" * 64, record_count=20 + ), + answers=BundleArtifact(path="oracle/answers.jsonl", sha256="7" * 64, record_count=12), + ) + + public_keys = set(public.model_dump()) + assert "observations" not in public_keys + assert "answers" not in public_keys + assert oracle.bundle_id == public.bundle_id + with pytest.raises(ValidationError, match="relative canonical path"): + BundleArtifact(path="../oracle/answers.jsonl", sha256="7" * 64, record_count=12) diff --git a/dimos/benchmark/spatiotemporal/test_observation_io.py b/dimos/benchmark/spatiotemporal/test_observation_io.py new file mode 100644 index 0000000000..a8976afeeb --- /dev/null +++ b/dimos/benchmark/spatiotemporal/test_observation_io.py @@ -0,0 +1,135 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for canonical teacher-observation persistence.""" + +from pathlib import Path + +import pytest + +from dimos.benchmark.spatiotemporal.models import BoundingBox2D, ObjectObservation +from dimos.benchmark.spatiotemporal.observation_io import read_observations, write_observations + + +def _observation(object_id: str, label: str) -> ObjectObservation: + return ObjectObservation( + episode_id="episode_1", + frame_id=12, + timestamp_s=0.5, + object_id=object_id, + label=label, + box=BoundingBox2D(x_min=0.1, y_min=0.2, x_max=0.3, y_max=0.5), + confidence=0.9, + ) + + +def test_observation_jsonl_round_trip_is_canonical_and_strict(tmp_path: Path) -> None: + path = tmp_path / "observations.jsonl" + mug = _observation("mug_1", "mug") + laptop = _observation("laptop_1", "laptop") + + write_observations(path, (mug, laptop)) + + expected = ( + b'{"box":{"x_max":0.3,"x_min":0.1,"y_max":0.5,"y_min":0.2},' + b'"confidence":0.9,"episode_id":"episode_1","frame_id":12,' + b'"label":"laptop","object_id":"laptop_1","timestamp_s":0.5}\n' + b'{"box":{"x_max":0.3,"x_min":0.1,"y_max":0.5,"y_min":0.2},' + b'"confidence":0.9,"episode_id":"episode_1","frame_id":12,' + b'"label":"mug","object_id":"mug_1","timestamp_s":0.5}\n' + ) + assert path.read_bytes() == expected + assert read_observations(path) == (laptop, mug) + write_observations(path, read_observations(path)) + assert path.read_bytes() == expected + + invalid_documents = ( + (expected.splitlines(keepends=True)[0] * 2, "duplicate observation identity"), + ( + expected.splitlines(keepends=True)[0] + + expected.splitlines(keepends=True)[0].replace( + b'"label":"laptop"', b'"label":"computer"' + ), + "conflicting observation identity", + ), + (expected.replace(b'"confidence":0.9', b'"confidence":NaN', 1), "invalid JSON"), + ( + expected.replace(b'"timestamp_s":0.5', b'"timestamp_s":1e999', 1), + "invalid observation", + ), + (expected.replace(b'"x_max":0.3', b'"x_max":1e999', 1), "invalid observation"), + (expected.replace(b'"x_min":0.1', b'"x_min":0.4', 1), "invalid observation"), + (expected.replace(b'"timestamp_s":0.5', b'"timestamp_s":-0.5', 1), "timestamp"), + (expected.replace(b'"label":"laptop"', '"label":"cafe\u0301"'.encode(), 1), "NFC"), + ) + for document, message in invalid_documents: + path.write_bytes(document) + with pytest.raises(ValueError, match=message): + read_observations(path) + + with pytest.raises(ValueError, match="duplicate observation identity"): + write_observations(path, (laptop, laptop)) + conflicting_laptop = laptop.model_copy(update={"label": "computer"}) + with pytest.raises(ValueError, match="conflicting observation identity"): + write_observations(path, (laptop, conflicting_laptop)) + + +def test_read_observations_rejects_duplicate_json_field_names(tmp_path: Path) -> None: + path = tmp_path / "observations.jsonl" + path.write_bytes( + b'{"box":{"x_max":0.3,"x_min":0.1,"y_max":0.5,"y_min":0.2},' + b'"confidence":0.9,"episode_id":"episode_1","frame_id":12,' + b'"label":"laptop","object_id":"mug_1","object_id":"laptop_1",' + b'"timestamp_s":0.5}\n' + ) + + with pytest.raises(ValueError, match="duplicate JSON field: object_id"): + read_observations(path) + + +@pytest.mark.parametrize("mutation", ("whitespace", "crlf", "no-newline", "out-of-order")) +def test_read_observations_rejects_noncanonical_jsonl(tmp_path: Path, mutation: str) -> None: + path = tmp_path / "observations.jsonl" + write_observations( + path, + (_observation("mug_1", "mug"), _observation("laptop_1", "laptop")), + ) + canonical = path.read_bytes() + lines = canonical.splitlines(keepends=True) + mutations = { + "whitespace": canonical.replace(b'"confidence":0.9', b'"confidence": 0.9', 1), + "crlf": canonical.replace(b"\n", b"\r\n"), + "no-newline": canonical.rstrip(b"\n"), + "out-of-order": b"".join(reversed(lines)), + } + path.write_bytes(mutations[mutation]) + + with pytest.raises(ValueError, match="canonical"): + read_observations(path) + + +@pytest.mark.parametrize("invalid_field", ("timestamp", "box", "label")) +def test_write_observations_revalidates_model_instances(tmp_path: Path, invalid_field: str) -> None: + path = tmp_path / "observations.jsonl" + observation = _observation("laptop_1", "laptop") + invalid_observations = { + "timestamp": observation.model_copy(update={"timestamp_s": float("inf")}), + "box": observation.model_copy( + update={"box": observation.box.model_copy(update={"x_min": 0.4})} + ), + "label": observation.model_copy(update={"label": "cafe\u0301"}), + } + + with pytest.raises(ValueError): + write_observations(path, (invalid_observations[invalid_field],)) diff --git a/dimos/benchmark/spatiotemporal/test_ports.py b/dimos/benchmark/spatiotemporal/test_ports.py new file mode 100644 index 0000000000..16d6e9c971 --- /dev/null +++ b/dimos/benchmark/spatiotemporal/test_ports.py @@ -0,0 +1,147 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for frozen perception and candidate callable ports.""" + +from collections.abc import Sequence +from hashlib import sha256 +from pathlib import Path + +from pydantic import ValidationError +import pytest + +from dimos.benchmark.spatiotemporal.models import ( + BoundingBox2D, + BundleArtifact, + ObjectObservation, + OracleBundleManifest, + PublicBundleManifest, + Question, +) +from dimos.benchmark.spatiotemporal.ports import ( + CandidateAnswerer, + CandidateReadiness, + DetectedObject, + ObservationBundleGenerator, + ReplayBundleResult, + replay_bundle_logical_sha256, +) +from dimos.benchmark.spatiotemporal.utilities import ( + SCHEMA_VERSION, + canonical_model_json, + stable_id, +) + + +def test_detected_object_contains_only_perception_seam_data() -> None: + detected = DetectedObject( + object_id="track_7", + label="mug", + box=BoundingBox2D(x_min=0.1, y_min=0.2, x_max=0.3, y_max=0.4), + confidence=0.9, + ) + + assert set(detected.model_dump()) == {"object_id", "label", "box", "confidence"} + + +def test_candidate_readiness_contains_no_teacher_or_oracle_data() -> None: + readiness = CandidateReadiness(ready=True, ingested_frame_count=24, detail=None) + + dumped = readiness.model_dump() + assert dumped == {"ready": True, "ingested_frame_count": 24, "detail": None} + forbidden = {"observations", "boxes", "intervals", "answers", "evidence", "oracle"} + assert forbidden.isdisjoint(dumped) + with pytest.raises(ValidationError, match="at least one frame"): + CandidateReadiness(ready=True, ingested_frame_count=0, detail=None) + + +def test_candidate_answerer_signature_is_expressible_without_private_records() -> None: + class FakeCandidate: + def ingest_video(self, video_path: Path) -> CandidateReadiness: + assert video_path == Path("episode.mp4") + return CandidateReadiness(ready=True, ingested_frame_count=1, detail=None) + + def answer(self, question: Question) -> str | bool | None: + return None + + def close(self) -> None: + return None + + candidate = FakeCandidate() + typed_candidate: CandidateAnswerer = candidate + assert typed_candidate.ingest_video(Path("episode.mp4")).ready is True + + +def test_observation_bundle_generator_has_root_independent_typed_result() -> None: + source_sha256 = "1" * 64 + bundle_id = stable_id( + "bundle", + { + "episode_id": "episode_1", + "schema_version": SCHEMA_VERSION, + "source_video_sha256": source_sha256, + }, + ) + public = PublicBundleManifest( + schema_version=SCHEMA_VERSION, + bundle_id=bundle_id, + episode_id="episode_1", + source_video_sha256=source_sha256, + episode=BundleArtifact(path="public/episode.json", sha256="2" * 64, record_count=1), + questions=BundleArtifact(path="public/questions.jsonl", sha256="3" * 64, record_count=12), + ) + public_sha256 = sha256(f"{canonical_model_json(public)}\n".encode()).hexdigest() + oracle = OracleBundleManifest( + schema_version=SCHEMA_VERSION, + bundle_id=bundle_id, + episode_id="episode_1", + source_video_sha256=source_sha256, + public_manifest_sha256=public_sha256, + observations=BundleArtifact( + path="oracle/observations.jsonl", sha256="5" * 64, record_count=30 + ), + relation_facts=BundleArtifact( + path="oracle/relation_facts.jsonl", sha256="6" * 64, record_count=20 + ), + relation_intervals=BundleArtifact( + path="oracle/relation_intervals.jsonl", sha256="7" * 64, record_count=8 + ), + answers=BundleArtifact(path="oracle/answers.jsonl", sha256="8" * 64, record_count=12), + ) + logical_sha256 = replay_bundle_logical_sha256(public, oracle) + result = ReplayBundleResult( + public_manifest=public, + oracle_manifest=oracle, + logical_sha256=logical_sha256, + ) + + class FakeGenerator: + def generate( + self, + observations: Sequence[ObjectObservation], + output_root: Path, + source_video_sha256: str, + ) -> ReplayBundleResult: + assert output_root == Path("release") + assert source_video_sha256 == source_sha256 + return result + + generator: ObservationBundleGenerator = FakeGenerator() + assert generator.generate((), Path("release"), source_sha256) == result + with pytest.raises(ValidationError, match="logical SHA-256"): + ReplayBundleResult( + public_manifest=public, + oracle_manifest=oracle, + logical_sha256="0" * 64, + ) diff --git a/dimos/benchmark/spatiotemporal/test_relations.py b/dimos/benchmark/spatiotemporal/test_relations.py new file mode 100644 index 0000000000..f2be4a5b21 --- /dev/null +++ b/dimos/benchmark/spatiotemporal/test_relations.py @@ -0,0 +1,272 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for image-plane relation derivation.""" + +from math import inf, nextafter + +import pytest + +from dimos.benchmark.spatiotemporal import relations +from dimos.benchmark.spatiotemporal.models import BoundingBox2D, ObjectObservation, SpatialPredicate +from dimos.benchmark.spatiotemporal.relations import derive_left_of + + +def _observation(object_id: str, label: str, box: BoundingBox2D) -> ObjectObservation: + return ObjectObservation( + episode_id="episode_1", + frame_id=12, + timestamp_s=0.5, + object_id=object_id, + label=label, + box=box, + confidence=0.9, + ) + + +def _transform_x( + observation: ObjectObservation, + *, + offset: float = 0.0, + mirror: bool = False, +) -> ObjectObservation: + box = observation.box + x_min, x_max = (1.0 - box.x_max, 1.0 - box.x_min) if mirror else (box.x_min, box.x_max) + return observation.model_copy( + update={"box": box.model_copy(update={"x_min": x_min + offset, "x_max": x_max + offset})} + ) + + +def test_accepts_left_of_relation_only_above_strict_margin() -> None: + mug = _observation( + "mug_1", + "mug", + BoundingBox2D(x_min=0.1, y_min=0.2, x_max=0.3, y_max=0.5), + ) + laptop = _observation( + "laptop_1", + "laptop", + BoundingBox2D(x_min=0.5, y_min=0.2, x_max=0.8, y_max=0.6), + ) + + relation = derive_left_of(mug, laptop, margin=0.1) + + assert relation is not None + assert relation.subject is mug + assert relation.predicate is SpatialPredicate.LEFT_OF + assert relation.object is laptop + assert relation.margin == 0.1 + assert derive_left_of(mug, laptop, margin=0.2 - 1e-12) is not None + assert derive_left_of(mug, laptop, margin=0.2) is None + assert derive_left_of(mug, laptop, margin=0.2 + 1e-12) is None + + +def test_equality_at_margin_remains_ambiguous_after_translation() -> None: + touching_margin = ( + _observation( + "mug_1", + "mug", + BoundingBox2D(x_min=0.0, y_min=0.2, x_max=0.125, y_max=0.5), + ), + _observation( + "laptop_1", + "laptop", + BoundingBox2D(x_min=0.25, y_min=0.2, x_max=0.5, y_max=0.6), + ), + ) + translated = ( + _transform_x(touching_margin[0], offset=0.125), + _transform_x(touching_margin[1], offset=0.125), + ) + + assert derive_left_of(*touching_margin, margin=0.125) is None + assert derive_left_of(*translated, margin=0.125) is None + + +def test_accepted_relation_remains_accepted_after_translation() -> None: + separated = ( + _observation( + "mug_1", + "mug", + BoundingBox2D(x_min=0.0, y_min=0.2, x_max=0.125, y_max=0.5), + ), + _observation( + "laptop_1", + "laptop", + BoundingBox2D(x_min=0.375, y_min=0.2, x_max=0.5, y_max=0.6), + ), + ) + translated = ( + _transform_x(separated[0], offset=0.125), + _transform_x(separated[1], offset=0.125), + ) + + assert derive_left_of(*separated, margin=0.125) is not None + assert derive_left_of(*translated, margin=0.125) is not None + + +def test_next_representable_separation_above_margin_is_accepted() -> None: + subject = _observation( + "subject_1", + "subject", + BoundingBox2D(x_min=0.0, y_min=0.2, x_max=0.0625, y_max=0.5), + ) + object_ = _observation( + "object_1", + "object", + BoundingBox2D( + x_min=nextafter(0.1875, inf), + y_min=0.2, + x_max=0.5, + y_max=0.6, + ), + ) + + assert derive_left_of(subject, object_, margin=0.125) is not None + + +@pytest.mark.parametrize( + ("subject_box", "object_box"), + [ + ( + BoundingBox2D(x_min=0.1, y_min=0.2, x_max=0.6, y_max=0.5), + BoundingBox2D(x_min=0.4, y_min=0.2, x_max=0.8, y_max=0.6), + ), + ( + BoundingBox2D(x_min=0.1, y_min=0.2, x_max=0.9, y_max=0.7), + BoundingBox2D(x_min=0.3, y_min=0.3, x_max=0.6, y_max=0.5), + ), + ], +) +def test_overlap_and_containment_are_ambiguous( + subject_box: BoundingBox2D, + object_box: BoundingBox2D, +) -> None: + subject = _observation("subject_1", "subject", subject_box) + object_ = _observation("object_1", "object", object_box) + + assert relations.derive_left_of(subject, object_, margin=0.0) is None + assert relations.derive_right_of(subject, object_, margin=0.0) is None + + +def test_horizontal_mirror_swaps_left_and_right() -> None: + mug = _observation( + "mug_1", + "mug", + BoundingBox2D(x_min=0.1, y_min=0.2, x_max=0.3, y_max=0.5), + ) + laptop = _observation( + "laptop_1", + "laptop", + BoundingBox2D(x_min=0.6, y_min=0.2, x_max=0.8, y_max=0.6), + ) + + original = derive_left_of(mug, laptop, margin=0.1) + mirrored = relations.derive_right_of( + _transform_x(mug, mirror=True), + _transform_x(laptop, mirror=True), + margin=0.1, + ) + + assert original is not None + assert mirrored is not None + assert mirrored.subject.object_id == original.subject.object_id + assert mirrored.object.object_id == original.object.object_id + + +def test_right_of_is_left_of_with_arguments_swapped() -> None: + mug = _observation( + "mug_1", + "mug", + BoundingBox2D(x_min=0.1, y_min=0.2, x_max=0.3, y_max=0.5), + ) + laptop = _observation( + "laptop_1", + "laptop", + BoundingBox2D(x_min=0.5, y_min=0.2, x_max=0.8, y_max=0.6), + ) + + relation = relations.derive_right_of(laptop, mug, margin=0.1) + + assert relation is not None + assert relation.subject is laptop + assert relation.predicate is SpatialPredicate.RIGHT_OF + assert relation.object is mug + assert relations.derive_right_of(mug, laptop, margin=0.1) is None + + +def test_vertical_predicates_are_strict_inverses() -> None: + lamp = _observation( + "lamp_1", + "lamp", + BoundingBox2D(x_min=0.2, y_min=0.1, x_max=0.5, y_max=0.3), + ) + table = _observation( + "table_1", + "table", + BoundingBox2D(x_min=0.1, y_min=0.6, x_max=0.9, y_max=0.8), + ) + + above = relations.derive_above(lamp, table, margin=0.2) + below = relations.derive_below(table, lamp, margin=0.2) + + assert above is not None + assert above.predicate is SpatialPredicate.ABOVE + assert below is not None + assert below.predicate is SpatialPredicate.BELOW + assert relations.derive_above(table, lamp, margin=0.2) is None + assert relations.derive_below(lamp, table, margin=0.2) is None + assert relations.derive_above(lamp, table, margin=0.3) is None + assert relations.derive_below(table, lamp, margin=0.3) is None + + +def test_rejects_invalid_margin_and_cross_sample_comparisons() -> None: + mug = _observation( + "mug_1", + "mug", + BoundingBox2D(x_min=0.1, y_min=0.2, x_max=0.3, y_max=0.5), + ) + laptop = _observation( + "laptop_1", + "laptop", + BoundingBox2D(x_min=0.5, y_min=0.2, x_max=0.8, y_max=0.6), + ) + + for invalid_margin in (-0.1, 1.1, float("nan"), float("inf")): + with pytest.raises(ValueError, match="margin"): + derive_left_of(mug, laptop, margin=invalid_margin) + + for update in ( + {"episode_id": "episode_2"}, + {"frame_id": 13}, + {"timestamp_s": 0.6}, + ): + other_sample = laptop.model_copy(update=update) + with pytest.raises(ValueError, match="same sample"): + derive_left_of(mug, other_sample, margin=0.1) + + +def test_same_object_identity_never_produces_a_relation() -> None: + left = _observation( + "mug_1", + "mug", + BoundingBox2D(x_min=0.1, y_min=0.2, x_max=0.3, y_max=0.5), + ) + right = _observation( + "mug_1", + "mug", + BoundingBox2D(x_min=0.5, y_min=0.2, x_max=0.8, y_max=0.6), + ) + + assert derive_left_of(left, right, margin=0.1) is None diff --git a/dimos/benchmark/spatiotemporal/test_replay.py b/dimos/benchmark/spatiotemporal/test_replay.py new file mode 100644 index 0000000000..5691d98a9c --- /dev/null +++ b/dimos/benchmark/spatiotemporal/test_replay.py @@ -0,0 +1,168 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Behavioral tests for deterministic observation replay.""" + +from pathlib import Path + +import pytest + +from dimos.benchmark.spatiotemporal import replay +from dimos.benchmark.spatiotemporal.bundles import load_bundle +from dimos.benchmark.spatiotemporal.models import BoundingBox2D, ObjectObservation +from dimos.benchmark.spatiotemporal.observation_io import read_observations, write_observations +from dimos.benchmark.spatiotemporal.ports import ( + ReplayInsufficiencyCode, + ReplayInsufficiencyError, +) +from dimos.benchmark.spatiotemporal.replay import ( + DeterministicObservationBundleGenerator, + replay_observations, +) + + +def _observation( + *, + frame_id: int, + object_id: str, + box: BoundingBox2D, + episode_id: str = "episode_1", +) -> ObjectObservation: + return ObjectObservation( + episode_id=episode_id, + frame_id=frame_id, + timestamp_s=frame_id / 10, + object_id=object_id, + label=object_id, + box=box, + confidence=0.9, + ) + + +def test_replays_saved_observations_to_root_independent_bundles(tmp_path: Path) -> None: + observations = ( + _observation( + frame_id=1, + object_id="red", + box=BoundingBox2D(x_min=0.1, y_min=0.2, x_max=0.2, y_max=0.4), + ), + _observation( + frame_id=1, + object_id="blue", + box=BoundingBox2D(x_min=0.7, y_min=0.2, x_max=0.8, y_max=0.4), + ), + _observation( + frame_id=3, + object_id="green", + box=BoundingBox2D(x_min=0.2, y_min=0.1, x_max=0.4, y_max=0.2), + ), + _observation( + frame_id=3, + object_id="yellow", + box=BoundingBox2D(x_min=0.2, y_min=0.7, x_max=0.4, y_max=0.8), + ), + ) + observations_path = tmp_path / "observations.jsonl" + write_observations(observations_path, observations) + + results = tuple( + replay_observations(observations_path, root, "1" * 64) + for root in (tmp_path / "first", tmp_path / "second") + ) + + assert results[0].logical_sha256 == results[1].logical_sha256 + for root, result in zip((tmp_path / "first", tmp_path / "second"), results, strict=True): + bundle = load_bundle(root) + assert bundle.public_manifest == result.public_manifest + assert bundle.oracle_manifest == result.oracle_manifest + assert bundle.observations == read_observations(observations_path) + assert bundle.relation_facts + assert bundle.relation_intervals + assert bundle.questions + assert len(bundle.answers) == len(bundle.questions) + + +@pytest.mark.parametrize( + ("observations", "expected_code", "expected_guidance"), + [ + ((), ReplayInsufficiencyCode.EMPTY_OBSERVATIONS, "capture at least one sampled frame"), + ( + ( + _observation( + frame_id=1, + object_id="red", + box=BoundingBox2D(x_min=0.1, y_min=0.1, x_max=0.2, y_max=0.2), + ), + _observation( + frame_id=2, + object_id="blue", + box=BoundingBox2D(x_min=0.7, y_min=0.7, x_max=0.8, y_max=0.8), + episode_id="episode_2", + ), + ), + ReplayInsufficiencyCode.MIXED_EPISODES, + "replay one episode at a time", + ), + ( + ( + _observation( + frame_id=1, + object_id="red", + box=BoundingBox2D(x_min=0.1, y_min=0.1, x_max=0.2, y_max=0.2), + ), + ), + ReplayInsufficiencyCode.NO_RELATIONS, + "spatially separated object pairs", + ), + ], +) +def test_reports_actionable_stable_insufficiency_reasons( + tmp_path: Path, + observations: tuple[ObjectObservation, ...], + expected_code: ReplayInsufficiencyCode, + expected_guidance: str, +) -> None: + generator = DeterministicObservationBundleGenerator() + + with pytest.raises(ReplayInsufficiencyError) as raised: + generator.generate(observations, tmp_path / "bundle", "1" * 64) + + assert raised.value.code is expected_code + assert expected_guidance in str(raised.value) + + +def test_reports_when_accepted_relations_produce_no_questions( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + observations = ( + _observation( + frame_id=1, + object_id="red", + box=BoundingBox2D(x_min=0.1, y_min=0.1, x_max=0.2, y_max=0.2), + ), + _observation( + frame_id=1, + object_id="blue", + box=BoundingBox2D(x_min=0.7, y_min=0.1, x_max=0.8, y_max=0.2), + ), + ) + monkeypatch.setattr(replay, "generate_spatial_questions", lambda facts: ()) + + with pytest.raises(ReplayInsufficiencyError) as raised: + DeterministicObservationBundleGenerator().generate( + observations, tmp_path / "bundle", "1" * 64 + ) + + assert raised.value.code is ReplayInsufficiencyCode.NO_QUESTIONS + assert "evaluation questions" in str(raised.value) diff --git a/dimos/benchmark/spatiotemporal/test_runner.py b/dimos/benchmark/spatiotemporal/test_runner.py new file mode 100644 index 0000000000..b35c83d312 --- /dev/null +++ b/dimos/benchmark/spatiotemporal/test_runner.py @@ -0,0 +1,128 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for candidate answer parsing and evidence-linked reporting.""" + +import pytest + +from dimos.benchmark.spatiotemporal.models import ( + OracleAnswer, + PredictionStatus, + Question, + QuestionKind, + SpatialPredicate, +) +from dimos.benchmark.spatiotemporal.runner import ( + build_evaluation_report, + parse_candidate_prediction, +) +from dimos.benchmark.spatiotemporal.utilities import SCHEMA_VERSION, stable_id + +QUESTION_ID = "question_4ef1579875e841ec7602de2e9cfc95f0bfdfb5e8d2e38ca798f462f8f72eda1e" + + +@pytest.mark.parametrize( + ("raw_answer", "expected", "expected_status", "expected_prediction"), + ( + (True, True, PredictionStatus.CORRECT, True), + (False, True, PredictionStatus.INCORRECT, False), + ("yes", True, PredictionStatus.CORRECT, True), + ("no", False, PredictionStatus.CORRECT, False), + (None, True, PredictionStatus.MISSING, None), + ("true", True, PredictionStatus.INVALID, None), + ("yes, definitely", True, PredictionStatus.INVALID, None), + ), +) +def test_parses_only_boolean_and_explicit_yes_no_answers( + raw_answer: str | bool | None, + expected: bool, + expected_status: PredictionStatus, + expected_prediction: bool | None, +) -> None: + status, prediction = parse_candidate_prediction(QUESTION_ID, raw_answer, expected) + + assert status is expected_status + if expected_prediction is None: + assert prediction is None + else: + assert prediction is not None + assert prediction.question_id == QUESTION_ID + assert prediction.answer is expected_prediction + + +def test_builds_evidence_linked_aggregate_report_with_filtered_diagnostics() -> None: + questions = tuple( + Question( + question_id=stable_id( + "question", + { + "object_ids": ("obj_red", "obj_blue"), + "predicate": predicate.value, + "question_kind": QuestionKind.SPATIAL.value, + "reference_ids": (), + "schema_version": SCHEMA_VERSION, + }, + ), + episode_id="episode_1", + text=f"Is the red object {predicate.value} the blue object?", + question_kind=QuestionKind.SPATIAL, + predicate=predicate, + object_ids=("obj_red", "obj_blue"), + ) + for predicate in (SpatialPredicate.LEFT_OF, SpatialPredicate.ABOVE) + ) + oracles = { + questions[0].question_id: OracleAnswer( + question_id=questions[0].question_id, + expected=True, + evidence_frame_ids=(4,), + ), + questions[1].question_id: OracleAnswer( + question_id=questions[1].question_id, + expected=False, + evidence_frame_ids=(9,), + ), + } + + report = build_evaluation_report( + questions, + oracles, + {questions[0].question_id: "yes"}, + source_video_sha256="a" * 64, + diagnostic_statuses={PredictionStatus.MISSING}, + ) + + assert report.source_video_sha256 == "a" * 64 + assert report.overall.total == 2 + assert report.overall.correct == 1 + assert report.overall.accuracy == 0.5 + assert report.status_counts == { + PredictionStatus.CORRECT: 1, + PredictionStatus.INCORRECT: 0, + PredictionStatus.MISSING: 1, + PredictionStatus.INVALID: 0, + } + assert report.by_family[QuestionKind.SPATIAL].total == 2 + assert report.by_predicate[SpatialPredicate.LEFT_OF].correct == 1 + assert report.by_predicate[SpatialPredicate.ABOVE].correct == 0 + assert len(report.diagnostics) == 1 + diagnostic = report.diagnostics[0] + assert diagnostic.question_id == questions[1].question_id + assert diagnostic.status is PredictionStatus.MISSING + assert diagnostic.question_kind is QuestionKind.SPATIAL + assert diagnostic.predicate is SpatialPredicate.ABOVE + assert diagnostic.expected is False + assert diagnostic.predicted is None + assert diagnostic.evidence_frame_ids == (9,) + assert diagnostic.evidence_interval_ids == () diff --git a/dimos/benchmark/spatiotemporal/test_scoring.py b/dimos/benchmark/spatiotemporal/test_scoring.py new file mode 100644 index 0000000000..df3597f88e --- /dev/null +++ b/dimos/benchmark/spatiotemporal/test_scoring.py @@ -0,0 +1,101 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for exact typed prediction scoring.""" + +import pytest + +from dimos.benchmark.spatiotemporal.models import ( + OracleAnswer, + Prediction, + Question, + QuestionKind, + SpatialPredicate, +) +from dimos.benchmark.spatiotemporal.scoring import score_prediction + + +def test_scores_exact_boolean_prediction_with_separate_oracle() -> None: + question = Question( + question_id="question_4ef1579875e841ec7602de2e9cfc95f0bfdfb5e8d2e38ca798f462f8f72eda1e", + episode_id="episode_1", + text="Is the mug left of the laptop at the end?", + question_kind=QuestionKind.SPATIAL, + predicate=SpatialPredicate.LEFT_OF, + object_ids=("obj_red", "obj_blue"), + ) + oracle = OracleAnswer( + question_id=question.question_id, + expected=True, + evidence_frame_ids=(12,), + ) + prediction = Prediction(question_id=question.question_id, answer=True) + + result = score_prediction(question, oracle, prediction) + + assert result.question_id == question.question_id + assert result.expected is True + assert result.predicted is True + assert result.correct is True + + +def test_scores_always_yes_incorrect_against_false_private_truth() -> None: + question = Question( + question_id="question_4ef1579875e841ec7602de2e9cfc95f0bfdfb5e8d2e38ca798f462f8f72eda1e", + episode_id="episode_1", + text="Is the mug left of the laptop at the end?", + question_kind=QuestionKind.SPATIAL, + predicate=SpatialPredicate.LEFT_OF, + object_ids=("obj_red", "obj_blue"), + ) + oracle = OracleAnswer( + question_id=question.question_id, + expected=False, + evidence_frame_ids=(12,), + ) + + result = score_prediction( + question, + oracle, + Prediction(question_id=question.question_id, answer=True), + ) + + assert result.correct is False + + +def test_rejects_oracle_or_prediction_for_another_question() -> None: + question_id = "question_4ef1579875e841ec7602de2e9cfc95f0bfdfb5e8d2e38ca798f462f8f72eda1e" + other_id = f"question_{'0' * 64}" + question = Question( + question_id=question_id, + episode_id="episode_1", + text="Is the mug left of the laptop at the end?", + question_kind=QuestionKind.SPATIAL, + predicate=SpatialPredicate.LEFT_OF, + object_ids=("obj_red", "obj_blue"), + ) + + mismatched_records = ( + ( + OracleAnswer(question_id=other_id, expected=True, evidence_frame_ids=(12,)), + Prediction(question_id=question_id, answer=True), + ), + ( + OracleAnswer(question_id=question_id, expected=True, evidence_frame_ids=(12,)), + Prediction(question_id=other_id, answer=True), + ), + ) + for oracle, prediction in mismatched_records: + with pytest.raises(ValueError, match="question IDs"): + score_prediction(question, oracle, prediction) diff --git a/dimos/benchmark/spatiotemporal/test_temporal_memory_answerer.py b/dimos/benchmark/spatiotemporal/test_temporal_memory_answerer.py new file mode 100644 index 0000000000..8ddf19e03c --- /dev/null +++ b/dimos/benchmark/spatiotemporal/test_temporal_memory_answerer.py @@ -0,0 +1,77 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the public-only TemporalMemory candidate adapter.""" + +from pathlib import Path + +import pytest + +from dimos.benchmark.spatiotemporal.models import Question, QuestionKind, SpatialPredicate +from dimos.benchmark.spatiotemporal.temporal_memory_answerer import TemporalMemoryAnswerer +from dimos.benchmark.spatiotemporal.utilities import SCHEMA_VERSION, stable_id + + +def test_temporal_memory_answerer_enforces_public_candidate_lifecycle(tmp_path: Path) -> None: + calls: list[tuple[str, object]] = [] + + def ingest(video_path: Path) -> int: + calls.append(("ingest", video_path)) + return 12 + + def query(question_text: str) -> str: + calls.append(("query", question_text)) + return "yes" + + def cleanup() -> None: + calls.append(("cleanup", None)) + + answerer = TemporalMemoryAnswerer(ingest=ingest, query=query, cleanup=cleanup) + question = Question( + question_id=stable_id( + "question", + { + "object_ids": ("obj_red", "obj_blue"), + "predicate": SpatialPredicate.LEFT_OF.value, + "question_kind": QuestionKind.SPATIAL.value, + "reference_ids": (), + "schema_version": SCHEMA_VERSION, + }, + ), + episode_id="episode_1", + text="Is the red object left of the blue object?", + question_kind=QuestionKind.SPATIAL, + predicate=SpatialPredicate.LEFT_OF, + object_ids=("obj_red", "obj_blue"), + ) + video_path = tmp_path / "public-video.mp4" + + with pytest.raises(RuntimeError, match="ingest"): + answerer.answer(question) + + readiness = answerer.ingest_video(video_path) + assert readiness.ready is True + assert readiness.ingested_frame_count == 12 + assert answerer.answer(question) == "yes" + + answerer.close() + answerer.close() + + assert calls == [ + ("ingest", video_path), + ("query", question.text), + ("cleanup", None), + ] + with pytest.raises(RuntimeError, match="closed"): + answerer.ingest_video(video_path) diff --git a/dimos/benchmark/spatiotemporal/test_utilities.py b/dimos/benchmark/spatiotemporal/test_utilities.py new file mode 100644 index 0000000000..5915ae31f2 --- /dev/null +++ b/dimos/benchmark/spatiotemporal/test_utilities.py @@ -0,0 +1,39 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for deterministic serialization helpers.""" + +import json + +from dimos.benchmark.spatiotemporal.models import Question, QuestionKind, SpatialPredicate +from dimos.benchmark.spatiotemporal.utilities import canonical_model_json + + +def test_serializes_strict_records_to_identical_canonical_json() -> None: + question = Question( + question_id="question_4ef1579875e841ec7602de2e9cfc95f0bfdfb5e8d2e38ca798f462f8f72eda1e", + episode_id="episode_1", + text="Is the mug left of the laptop at the end?", + question_kind=QuestionKind.SPATIAL, + predicate=SpatialPredicate.LEFT_OF, + object_ids=("obj_red", "obj_blue"), + ) + + first = canonical_model_json(question) + second = canonical_model_json(question) + + assert first == second + assert json.loads(first) == question.model_dump(mode="json") + assert ": " not in first + assert ", " not in first diff --git a/dimos/benchmark/spatiotemporal/test_video_adapter.py b/dimos/benchmark/spatiotemporal/test_video_adapter.py new file mode 100644 index 0000000000..fd55e5c395 --- /dev/null +++ b/dimos/benchmark/spatiotemporal/test_video_adapter.py @@ -0,0 +1,182 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pathlib import Path + +import cv2 +import numpy as np +import pytest + +from dimos.benchmark.spatiotemporal.models import BoundingBox2D +from dimos.benchmark.spatiotemporal.ports import DetectedObject +from dimos.benchmark.spatiotemporal.video_adapter import OpenCVVideoSampler +from dimos.msgs.sensor_msgs.Image import Image, ImageFormat + + +class _FakeCapture: + def __init__( + self, + frames: list[np.ndarray], + *, + declared_frame_count: int | None = None, + fps: float = 2.0, + opened: bool = True, + ) -> None: + self._frames = iter(frames) + self._declared_frame_count = ( + len(frames) if declared_frame_count is None else declared_frame_count + ) + self._fps = fps + self._opened = opened + self.released = False + + def isOpened(self) -> bool: + return self._opened + + def get(self, property_id: int) -> float: + if property_id == cv2.CAP_PROP_FPS: + return self._fps + if property_id == cv2.CAP_PROP_FRAME_COUNT: + return float(self._declared_frame_count) + raise AssertionError(f"unexpected capture property: {property_id}") + + def read(self) -> tuple[bool, np.ndarray | None]: + frame = next(self._frames, None) + return frame is not None, frame + + def release(self) -> None: + self.released = True + + +class _FakeDetector: + def __init__(self) -> None: + self.images: list[Image] = [] + self.closed = False + + def detect(self, image: Image) -> tuple[DetectedObject, ...]: + self.images.append(image) + return ( + DetectedObject( + object_id="mug-1", + label="mug", + box=BoundingBox2D(x_min=0.25, y_min=0.25, x_max=0.75, y_max=0.75), + confidence=0.9, + ), + ) + + def close(self) -> None: + self.closed = True + + +def test_sampler_deterministically_normalizes_sampled_frames_and_cleans_up( + monkeypatch: pytest.MonkeyPatch, +) -> None: + frames = [ + np.full((2, 3, 3), (index, index + 1, index + 2), dtype=np.uint8) for index in range(4) + ] + capture = _FakeCapture(frames) + detector = _FakeDetector() + monkeypatch.setattr(cv2, "VideoCapture", lambda _: capture) + + sampler = OpenCVVideoSampler(detector=detector, frame_stride=2) + observations = sampler.sample(Path("episode.mp4"), episode_id="episode-1") + sampler.close() + + assert [(item.frame_id, item.timestamp_s) for item in observations] == [ + (0, 0.0), + (2, 1.0), + ] + assert all(item.episode_id == "episode-1" for item in observations) + assert all(item.object_id == "mug-1" for item in observations) + assert [image.format for image in detector.images] == [ImageFormat.RGB, ImageFormat.RGB] + assert detector.images[0].frame_id == "0" + assert detector.images[0].ts == 0.0 + np.testing.assert_array_equal(detector.images[0].data[0, 0], np.array([2, 1, 0])) + assert capture.released + assert detector.closed + + +def test_sampler_rejects_non_positive_frame_stride() -> None: + with pytest.raises(ValueError, match="frame_stride must be positive"): + OpenCVVideoSampler(detector=_FakeDetector(), frame_stride=0) + + +def test_sampler_reports_early_decode_failure_and_releases_capture( + monkeypatch: pytest.MonkeyPatch, +) -> None: + capture = _FakeCapture( + [np.zeros((2, 3, 3), dtype=np.uint8)], + declared_frame_count=2, + ) + monkeypatch.setattr(cv2, "VideoCapture", lambda _: capture) + + sampler = OpenCVVideoSampler(detector=_FakeDetector()) + with pytest.raises(RuntimeError, match="failed to decode frame 1"): + sampler.sample(Path("truncated.mp4"), episode_id="episode-1") + + assert capture.released + + +def test_sampler_reports_unopened_video_and_releases_capture( + monkeypatch: pytest.MonkeyPatch, +) -> None: + capture = _FakeCapture([], opened=False) + monkeypatch.setattr(cv2, "VideoCapture", lambda _: capture) + + sampler = OpenCVVideoSampler(detector=_FakeDetector()) + with pytest.raises(RuntimeError, match="failed to open video: missing.mp4"): + sampler.sample(Path("missing.mp4"), episode_id="episode-1") + + assert capture.released + + +def test_sampler_reports_zero_frame_video_and_releases_capture( + monkeypatch: pytest.MonkeyPatch, +) -> None: + capture = _FakeCapture([]) + monkeypatch.setattr(cv2, "VideoCapture", lambda _: capture) + + sampler = OpenCVVideoSampler(detector=_FakeDetector()) + with pytest.raises(RuntimeError, match="video contains no decodable frames"): + sampler.sample(Path("empty.mp4"), episode_id="episode-1") + + assert capture.released + + +@pytest.mark.parametrize("fps", [0.0, -1.0, float("nan")]) +def test_sampler_rejects_invalid_fps_and_releases_capture( + monkeypatch: pytest.MonkeyPatch, + fps: float, +) -> None: + capture = _FakeCapture([np.zeros((2, 3, 3), dtype=np.uint8)], fps=fps) + monkeypatch.setattr(cv2, "VideoCapture", lambda _: capture) + + sampler = OpenCVVideoSampler(detector=_FakeDetector()) + with pytest.raises(ValueError, match="video FPS must be finite and positive"): + sampler.sample(Path("invalid-fps.mp4"), episode_id="episode-1") + + assert capture.released + + +def test_sampler_rejects_non_bgr_frame_dimensions_and_releases_capture( + monkeypatch: pytest.MonkeyPatch, +) -> None: + capture = _FakeCapture([np.zeros((2, 3), dtype=np.uint8)]) + monkeypatch.setattr(cv2, "VideoCapture", lambda _: capture) + + sampler = OpenCVVideoSampler(detector=_FakeDetector()) + with pytest.raises(ValueError, match="decoded frame 0 must have BGR dimensions"): + sampler.sample(Path("grayscale.mp4"), episode_id="episode-1") + + assert capture.released diff --git a/dimos/benchmark/spatiotemporal/test_yoloe_adapter.py b/dimos/benchmark/spatiotemporal/test_yoloe_adapter.py new file mode 100644 index 0000000000..c3d79644ff --- /dev/null +++ b/dimos/benchmark/spatiotemporal/test_yoloe_adapter.py @@ -0,0 +1,219 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dataclasses import dataclass + +import numpy as np +import pytest + +from dimos.benchmark.spatiotemporal.yoloe_adapter import YoloeObservationDetector +from dimos.msgs.sensor_msgs.Image import Image, ImageFormat + + +@dataclass +class _FakeDetection: + bbox: tuple[float, float, float, float] + track_id: int + confidence: float + name: str + + +class _FakeYoloeDetector: + def __init__(self, frames: list[list[_FakeDetection]]) -> None: + self._frames = iter(frames) + self.images: list[Image] = [] + self.cleanup_calls: list[str] = [] + self.fail_stop = False + self.fail_prompts = False + self.prompts: list[str] | None = None + + def set_prompts(self, text: list[str]) -> None: + self.prompts = text + if self.fail_prompts: + raise RuntimeError("prompt setup failed") + + def process_image(self, image: Image) -> object: + self.images.append(image) + return type("Detections", (), {"detections": next(self._frames)})() + + def stop(self) -> None: + self.cleanup_calls.append("stop") + if self.fail_stop: + raise RuntimeError("stop failed") + + def close(self) -> None: + self.cleanup_calls.append("close") + + +def _image() -> Image: + return Image.from_numpy( + np.zeros((100, 200, 3), dtype=np.uint8), + format=ImageFormat.RGB, + ) + + +def test_adapter_preserves_native_ids_and_uses_unique_prompt_fallback() -> None: + detector = _FakeYoloeDetector( + [ + [ + _FakeDetection((20.0, 10.0, 60.0, 50.0), 17, 0.9, "bottle"), + _FakeDetection((100.0, 20.0, 180.0, 80.0), -1, 0.8, "mug"), + ] + ] + ) + adapter = YoloeObservationDetector( + prompts=("bottle", "mug"), + detector_factory=lambda: detector, + ) + + detections = adapter.detect(_image()) + + assert [(item.object_id, item.label) for item in detections] == [ + ("17", "bottle"), + ("mug", "mug"), + ] + assert detections[0].box.model_dump() == { + "x_min": 0.1, + "y_min": 0.1, + "x_max": 0.3, + "y_max": 0.5, + } + assert detector.images + + +def test_adapter_rejects_duplicate_fallback_labels() -> None: + detector = _FakeYoloeDetector( + [ + [ + _FakeDetection((20.0, 10.0, 60.0, 50.0), -1, 0.9, "mug"), + _FakeDetection((100.0, 20.0, 180.0, 80.0), -1, 0.8, "mug"), + ] + ] + ) + adapter = YoloeObservationDetector( + prompts=("mug",), + detector_factory=lambda: detector, + ) + + with pytest.raises(ValueError, match="duplicate fallback label: mug"): + adapter.detect(_image()) + + +def test_adapter_rejects_fallback_when_tracked_detection_has_same_label() -> None: + detector = _FakeYoloeDetector( + [ + [ + _FakeDetection((20.0, 10.0, 60.0, 50.0), 17, 0.9, "mug"), + _FakeDetection((100.0, 20.0, 180.0, 80.0), -1, 0.8, "mug"), + ] + ] + ) + adapter = YoloeObservationDetector( + prompts=("mug",), + detector_factory=lambda: detector, + ) + + with pytest.raises(ValueError, match="duplicate fallback label: mug"): + adapter.detect(_image()) + + +def test_adapter_rejects_fallback_for_non_prompt_label() -> None: + detector = _FakeYoloeDetector([[_FakeDetection((20.0, 10.0, 60.0, 50.0), -1, 0.9, "plate")]]) + adapter = YoloeObservationDetector( + prompts=("mug",), + detector_factory=lambda: detector, + ) + + with pytest.raises(ValueError, match="fallback label is not a configured prompt: plate"): + adapter.detect(_image()) + + +def test_adapter_records_continuity_and_drop_statistics() -> None: + detector = _FakeYoloeDetector( + [ + [ + _FakeDetection((20.0, 10.0, 60.0, 50.0), 17, 0.9, "bottle"), + _FakeDetection((100.0, 20.0, 180.0, 80.0), -1, 0.8, "mug"), + ], + [ + _FakeDetection((25.0, 10.0, 65.0, 50.0), 17, 0.9, "bottle"), + _FakeDetection((105.0, 20.0, 185.0, 80.0), 18, 0.8, "mug"), + ], + [_FakeDetection((110.0, 20.0, 190.0, 80.0), 18, 0.8, "mug")], + ] + ) + adapter = YoloeObservationDetector( + prompts=("bottle", "mug"), + detector_factory=lambda: detector, + ) + + adapter.detect(_image()) + adapter.detect(_image()) + adapter.detect(_image()) + + assert adapter.statistics.frames_processed == 3 + assert adapter.statistics.detections_emitted == 5 + assert adapter.statistics.fallback_detections == 1 + assert adapter.statistics.continued_tracks == 2 + assert adapter.statistics.dropped_tracks == 2 + + +def test_adapter_closes_detector_even_when_stop_fails() -> None: + detector = _FakeYoloeDetector([[]]) + detector.fail_stop = True + adapter = YoloeObservationDetector( + prompts=("mug",), + detector_factory=lambda: detector, + ) + adapter.detect(_image()) + + with pytest.raises(RuntimeError, match="stop failed"): + adapter.close() + + assert detector.cleanup_calls == ["stop", "close"] + + +def test_close_before_detection_is_terminal_without_constructing_detector() -> None: + constructions = 0 + + def factory() -> _FakeYoloeDetector: + nonlocal constructions + constructions += 1 + return _FakeYoloeDetector([[]]) + + adapter = YoloeObservationDetector(prompts=("mug",), detector_factory=factory) + + adapter.close() + + with pytest.raises(RuntimeError, match="detector is closed"): + adapter.detect(_image()) + assert constructions == 0 + + +def test_failed_prompt_setup_cleans_up_and_retries_construction() -> None: + failed = _FakeYoloeDetector([[]]) + failed.fail_prompts = True + recovered = _FakeYoloeDetector([[]]) + candidates = iter((failed, recovered)) + adapter = YoloeObservationDetector( + prompts=("mug",), + detector_factory=lambda: next(candidates), + ) + + with pytest.raises(RuntimeError, match="prompt setup failed"): + adapter.detect(_image()) + + assert failed.cleanup_calls == ["stop", "close"] + assert adapter.detect(_image()) == () + assert recovered.prompts == ["mug"] diff --git a/dimos/benchmark/spatiotemporal/utilities.py b/dimos/benchmark/spatiotemporal/utilities.py new file mode 100644 index 0000000000..5e420b5793 --- /dev/null +++ b/dimos/benchmark/spatiotemporal/utilities.py @@ -0,0 +1,48 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Deterministic serialization and stable-ID helpers.""" + +from collections.abc import Mapping, Sequence +from hashlib import sha256 +import json +from typing import Final, Literal, TypeAlias + +from pydantic import BaseModel + +JsonValue: TypeAlias = ( + str | bool | None | int | float | Mapping[str, "JsonValue"] | Sequence["JsonValue"] +) +SchemaVersion: TypeAlias = Literal["spatiotemporal-video-qa/v1"] +SCHEMA_VERSION: Final[SchemaVersion] = "spatiotemporal-video-qa/v1" + + +def canonical_json_bytes(value: JsonValue) -> bytes: + """Serialize the benchmark's string-only ID preimages deterministically.""" + return json.dumps( + value, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + + +def stable_id(prefix: str, preimage: JsonValue) -> str: + """Hash one canonical preimage into an opaque stable identifier.""" + return f"{prefix}_{sha256(canonical_json_bytes(preimage)).hexdigest()}" + + +def canonical_model_json(model: BaseModel) -> str: + """Serialize one strict record to deterministic canonical JSON.""" + return canonical_json_bytes(model.model_dump(mode="json")).decode("utf-8") diff --git a/dimos/benchmark/spatiotemporal/video_adapter.py b/dimos/benchmark/spatiotemporal/video_adapter.py new file mode 100644 index 0000000000..85d6975f77 --- /dev/null +++ b/dimos/benchmark/spatiotemporal/video_adapter.py @@ -0,0 +1,88 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Deterministic OpenCV video sampling through the frozen detector seam.""" + +import math +from pathlib import Path + +import cv2 + +from dimos.benchmark.spatiotemporal.models import ObjectObservation +from dimos.benchmark.spatiotemporal.ports import ObservationDetector +from dimos.msgs.sensor_msgs.Image import Image, ImageFormat + + +class OpenCVVideoSampler: + """Sample decoded video frames and normalize detector results.""" + + def __init__(self, detector: ObservationDetector, frame_stride: int = 1) -> None: + if frame_stride < 1: + raise ValueError("frame_stride must be positive") + self._detector = detector + self._frame_stride = frame_stride + self._closed = False + + def sample(self, video_path: Path, episode_id: str) -> tuple[ObjectObservation, ...]: + """Return detector observations for every selected source frame.""" + capture = cv2.VideoCapture(str(video_path)) + observations: list[ObjectObservation] = [] + try: + if not capture.isOpened(): + raise RuntimeError(f"failed to open video: {video_path}") + fps = capture.get(cv2.CAP_PROP_FPS) + if not math.isfinite(fps) or fps <= 0.0: + raise ValueError("video FPS must be finite and positive") + declared_frame_count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT)) + frame_id = 0 + while True: + decoded, frame = capture.read() + if not decoded: + if frame_id == 0: + raise RuntimeError("video contains no decodable frames") + if frame_id < declared_frame_count: + raise RuntimeError(f"failed to decode frame {frame_id}") + break + if frame is None or frame.ndim != 3 or frame.shape[2] != 3: + raise ValueError(f"decoded frame {frame_id} must have BGR dimensions") + if frame_id % self._frame_stride == 0: + timestamp_s = frame_id / fps + image = Image.from_numpy( + cv2.cvtColor(frame, cv2.COLOR_BGR2RGB), + format=ImageFormat.RGB, + frame_id=str(frame_id), + ts=timestamp_s, + ) + observations.extend( + ObjectObservation( + episode_id=episode_id, + frame_id=frame_id, + timestamp_s=timestamp_s, + object_id=detection.object_id, + label=detection.label, + box=detection.box, + confidence=detection.confidence, + ) + for detection in self._detector.detect(image) + ) + frame_id += 1 + finally: + capture.release() + return tuple(observations) + + def close(self) -> None: + """Release the detector owned by this sampler.""" + if not self._closed: + self._detector.close() + self._closed = True diff --git a/dimos/benchmark/spatiotemporal/yoloe_adapter.py b/dimos/benchmark/spatiotemporal/yoloe_adapter.py new file mode 100644 index 0000000000..bb018ef487 --- /dev/null +++ b/dimos/benchmark/spatiotemporal/yoloe_adapter.py @@ -0,0 +1,148 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Lazy YOLO-E adapter for the frozen observation-detector seam.""" + +from collections import Counter +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from typing import Any + +from dimos.benchmark.spatiotemporal.models import BoundingBox2D +from dimos.benchmark.spatiotemporal.ports import DetectedObject +from dimos.msgs.sensor_msgs.Image import Image + + +@dataclass(frozen=True) +class YoloeAdapterStatistics: + """Accumulated identity continuity statistics.""" + + frames_processed: int + detections_emitted: int + fallback_detections: int + continued_tracks: int + dropped_tracks: int + + +class YoloeObservationDetector: + """Normalize persisted YOLO-E detections while preserving object identity.""" + + def __init__( + self, + prompts: Sequence[str], + detector_factory: Callable[[], Any] | None = None, + ) -> None: + self._prompts = tuple(prompts) + self._detector_factory = detector_factory + self._detector: Any | None = None + self._closed = False + self._previous_object_ids: set[str] = set() + self._frames_processed = 0 + self._detections_emitted = 0 + self._fallback_detections = 0 + self._continued_tracks = 0 + self._dropped_tracks = 0 + + def detect(self, image: Image) -> tuple[DetectedObject, ...]: + """Run persisted tracking and normalize its detections.""" + if self._closed: + raise RuntimeError("detector is closed") + detector = self._get_detector() + result = detector.process_image(image) + label_counts = Counter(detection.name for detection in result.detections) + fallback_labels: set[str] = set() + for detection in result.detections: + if detection.track_id == -1: + if detection.name not in self._prompts: + raise ValueError(f"fallback label is not a configured prompt: {detection.name}") + if label_counts[detection.name] > 1: + raise ValueError(f"duplicate fallback label: {detection.name}") + fallback_labels.add(detection.name) + normalized = tuple(self._normalize(detection, image) for detection in result.detections) + object_ids = {detection.object_id for detection in normalized} + self._frames_processed += 1 + self._detections_emitted += len(normalized) + self._fallback_detections += len(fallback_labels) + self._continued_tracks += len(self._previous_object_ids & object_ids) + self._dropped_tracks += len(self._previous_object_ids - object_ids) + self._previous_object_ids = object_ids + return normalized + + @property + def statistics(self) -> YoloeAdapterStatistics: + """Return an immutable snapshot of accumulated identity statistics.""" + return YoloeAdapterStatistics( + frames_processed=self._frames_processed, + detections_emitted=self._detections_emitted, + fallback_detections=self._fallback_detections, + continued_tracks=self._continued_tracks, + dropped_tracks=self._dropped_tracks, + ) + + def close(self) -> None: + """Release an initialized detector without forcing lazy construction.""" + if self._closed: + return + self._closed = True + if self._detector is None: + return + self._cleanup_detector(self._detector) + + @staticmethod + def _cleanup_detector(detector: Any) -> None: + try: + stop = getattr(detector, "stop", None) + if stop is not None: + stop() + finally: + close = getattr(detector, "close", None) + if close is not None: + close() + + def _get_detector(self) -> Any: + if self._detector is None: + if self._detector_factory is not None: + detector = self._detector_factory() + else: + from dimos.perception.detection.detectors.yoloe import ( + Yoloe2DDetector, + YoloePromptMode, + ) + + detector = Yoloe2DDetector(prompt_mode=YoloePromptMode.PROMPT) + try: + detector.set_prompts(text=list(self._prompts)) + except Exception: + try: + self._cleanup_detector(detector) + finally: + raise + self._detector = detector + return self._detector + + @staticmethod + def _normalize(detection: Any, image: Image) -> DetectedObject: + x_min, y_min, x_max, y_max = detection.bbox + object_id = str(detection.track_id) if detection.track_id != -1 else detection.name + return DetectedObject( + object_id=object_id, + label=detection.name, + box=BoundingBox2D( + x_min=x_min / image.width, + y_min=y_min / image.height, + x_max=x_max / image.width, + y_max=y_max / image.height, + ), + confidence=detection.confidence, + ) From e69bd9db137256f2a5e013e12c9489fed2b6b778 Mon Sep 17 00:00:00 2001 From: fengweit <2412561+fengweit@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:16:58 +0800 Subject: [PATCH 02/17] fix(benchmark): preserve sparse video sample intervals --- dimos/benchmark/spatiotemporal/demo.py | 24 ++++++-- dimos/benchmark/spatiotemporal/intervals.py | 43 ++++++++++++-- dimos/benchmark/spatiotemporal/replay.py | 15 ++++- .../spatiotemporal/test_intervals.py | 28 +++++++++ dimos/benchmark/spatiotemporal/test_replay.py | 59 +++++++++++++++++++ .../spatiotemporal/test_video_adapter.py | 19 ++++++ .../benchmark/spatiotemporal/video_adapter.py | 9 +++ 7 files changed, 182 insertions(+), 15 deletions(-) diff --git a/dimos/benchmark/spatiotemporal/demo.py b/dimos/benchmark/spatiotemporal/demo.py index a82ec2e8ac..c37d75c0dc 100644 --- a/dimos/benchmark/spatiotemporal/demo.py +++ b/dimos/benchmark/spatiotemporal/demo.py @@ -220,12 +220,16 @@ def _detector_factory() -> Yoloe2DDetector: def _sample_observations( video: Path, frame_stride: int -) -> tuple[tuple[ObjectObservation, ...], YoloeAdapterStatistics]: +) -> tuple[ + tuple[ObjectObservation, ...], + YoloeAdapterStatistics, + tuple[tuple[int, float], ...], +]: detector = YoloeObservationDetector(prompts=DEFAULT_PROMPTS, detector_factory=_detector_factory) sampler = OpenCVVideoSampler(detector=detector, frame_stride=frame_stride) try: observations = sampler.sample(video, "office_robot_25s") - return observations, detector.statistics + return observations, detector.statistics, sampler.sample_schedule finally: sampler.close() @@ -240,9 +244,15 @@ def _teacher_run( second_root = _safe_child(output_root, "bundle-b") _reset_directory(first_root) _reset_directory(second_root) - observations, detector_statistics = _sample_observations(video, frame_stride) - repeated_observations, repeated_statistics = _sample_observations(video, frame_stride) - if observations != repeated_observations or detector_statistics != repeated_statistics: + observations, detector_statistics, sample_schedule = _sample_observations(video, frame_stride) + repeated_observations, repeated_statistics, repeated_schedule = _sample_observations( + video, frame_stride + ) + if ( + observations != repeated_observations + or detector_statistics != repeated_statistics + or sample_schedule != repeated_schedule + ): raise RuntimeError("YOLO-E observations differ across independent detector runs") write_observations(observations_path, observations) source_video_sha256 = sha256(video.read_bytes()).hexdigest() @@ -250,11 +260,13 @@ def _teacher_run( observations_path, first_root, source_video_sha256, + sample_schedule=sample_schedule, ) second = replay_observations( observations_path, second_root, source_video_sha256, + sample_schedule=sample_schedule, ) if first.logical_sha256 != second.logical_sha256: raise RuntimeError("bundle logical hashes differ across output roots") @@ -275,7 +287,7 @@ def _teacher_run( "questions": len(first_bundle.questions), "relation_facts": len(first_bundle.relation_facts), "relation_intervals": len(first_bundle.relation_intervals), - "sampled_frames": len({observation.frame_id for observation in observations}), + "sampled_frames": len(sample_schedule), "source_video_sha256": source_video_sha256, "spatial_questions": sum( question.question_kind.value == "spatial" for question in first_bundle.questions diff --git a/dimos/benchmark/spatiotemporal/intervals.py b/dimos/benchmark/spatiotemporal/intervals.py index 0be905019a..bfe9a69199 100644 --- a/dimos/benchmark/spatiotemporal/intervals.py +++ b/dimos/benchmark/spatiotemporal/intervals.py @@ -78,9 +78,34 @@ def _build_interval(facts: Sequence[RelationFact]) -> RelationInterval: ) -def build_relation_intervals(facts: Sequence[RelationFact]) -> tuple[RelationInterval, ...]: - """Coalesce consecutive facts that share episode and relation identity.""" +def _sample_positions( + facts: Sequence[RelationFact], sample_schedule: Sequence[tuple[int, float]] +) -> dict[int, int]: + if any( + current_frame >= next_frame or current_timestamp >= next_timestamp + for (current_frame, current_timestamp), (next_frame, next_timestamp) in pairwise( + sample_schedule + ) + ): + raise ValueError("conflicting sample schedule") + schedule_by_frame = dict(sample_schedule) + if len(schedule_by_frame) != len(sample_schedule): + raise ValueError("conflicting sample schedule") + if any(schedule_by_frame.get(fact.frame_id) != fact.timestamp_s for fact in facts): + raise ValueError("fact is absent from sample schedule") + return {frame_id: index for index, (frame_id, _) in enumerate(sample_schedule)} + + +def build_relation_intervals( + facts: Sequence[RelationFact], + *, + sample_schedule: Sequence[tuple[int, float]] | None = None, +) -> tuple[RelationInterval, ...]: + """Coalesce stable relations across adjacent sampled frames.""" _validate_sample_schedules(facts) + sample_positions = ( + _sample_positions(facts, sample_schedule) if sample_schedule is not None else None + ) ordered = sorted( facts, key=lambda fact: ( @@ -92,11 +117,17 @@ def build_relation_intervals(facts: Sequence[RelationFact]) -> tuple[RelationInt ) groups: list[list[RelationFact]] = [] for fact in ordered: + previous = groups[-1][-1] if groups else None + adjacent = ( + sample_positions[fact.frame_id] == sample_positions[previous.frame_id] + 1 + if sample_positions is not None and previous is not None + else previous is not None and fact.frame_id == previous.frame_id + 1 + ) if ( - groups - and fact.episode_id == groups[-1][-1].episode_id - and fact.relation_id == groups[-1][-1].relation_id - and fact.frame_id == groups[-1][-1].frame_id + 1 + previous is not None + and fact.episode_id == previous.episode_id + and fact.relation_id == previous.relation_id + and adjacent ): groups[-1].append(fact) else: diff --git a/dimos/benchmark/spatiotemporal/replay.py b/dimos/benchmark/spatiotemporal/replay.py index c7dc1da3cd..2faa51ad5c 100644 --- a/dimos/benchmark/spatiotemporal/replay.py +++ b/dimos/benchmark/spatiotemporal/replay.py @@ -116,8 +116,14 @@ def _spatial_answer(question: Question, facts: Sequence[RelationFact]) -> Oracle class DeterministicObservationBundleGenerator: """Generate deterministic bundles from canonical object observations.""" - def __init__(self, *, relation_margin: float = 0.0) -> None: + def __init__( + self, + *, + relation_margin: float = 0.0, + sample_schedule: Sequence[tuple[int, float]] | None = None, + ) -> None: self._relation_margin = relation_margin + self._sample_schedule = tuple(sample_schedule) if sample_schedule is not None else None def generate( self, @@ -144,7 +150,7 @@ def generate( ReplayInsufficiencyCode.NO_RELATIONS, "saved observations contain no spatially separated object pairs", ) - intervals = build_relation_intervals(facts) + intervals = build_relation_intervals(facts, sample_schedule=self._sample_schedule) spatial_questions = generate_spatial_questions(facts) temporal_cases = generate_temporal_question_cases(intervals) questions = tuple( @@ -191,10 +197,13 @@ def replay_observations( output_root: Path, source_video_sha256: str, *, + sample_schedule: Sequence[tuple[int, float]] | None = None, generator: ObservationBundleGenerator | None = None, ) -> ReplayBundleResult: """Read canonical saved observations and generate an evaluation bundle.""" + if generator is not None and sample_schedule is not None: + raise ValueError("sample_schedule cannot be combined with a custom generator") observations = read_observations(observations_path) if generator is None: - generator = DeterministicObservationBundleGenerator() + generator = DeterministicObservationBundleGenerator(sample_schedule=sample_schedule) return generator.generate(observations, output_root, source_video_sha256) diff --git a/dimos/benchmark/spatiotemporal/test_intervals.py b/dimos/benchmark/spatiotemporal/test_intervals.py index 31c8bebb54..4bc99463eb 100644 --- a/dimos/benchmark/spatiotemporal/test_intervals.py +++ b/dimos/benchmark/spatiotemporal/test_intervals.py @@ -57,6 +57,34 @@ def _fact( ) +def test_coalesces_relations_at_consecutive_sparse_samples() -> None: + from dimos.benchmark.spatiotemporal.intervals import build_relation_intervals + + intervals = build_relation_intervals( + (_fact(0, 0.0), _fact(150, 5.0)), + sample_schedule=((0, 0.0), (150, 5.0), (300, 10.0)), + ) + + assert len(intervals) == 1 + assert intervals[0].start_frame_id == 0 + assert intervals[0].end_frame_id == 150 + assert intervals[0].evidence_frame_ids == (0, 150) + + +def test_relation_absent_at_intervening_sample_breaks_interval() -> None: + from dimos.benchmark.spatiotemporal.intervals import build_relation_intervals + + intervals = build_relation_intervals( + (_fact(0, 0.0), _fact(300, 10.0)), + sample_schedule=((0, 0.0), (150, 5.0), (300, 10.0)), + ) + + assert [(interval.start_frame_id, interval.end_frame_id) for interval in intervals] == [ + (0, 0), + (300, 300), + ] + + def test_coalesces_only_consecutive_samples_with_stable_relation_identity() -> None: from dimos.benchmark.spatiotemporal.intervals import build_relation_intervals diff --git a/dimos/benchmark/spatiotemporal/test_replay.py b/dimos/benchmark/spatiotemporal/test_replay.py index 5691d98a9c..744dc48524 100644 --- a/dimos/benchmark/spatiotemporal/test_replay.py +++ b/dimos/benchmark/spatiotemporal/test_replay.py @@ -93,6 +93,65 @@ def test_replays_saved_observations_to_root_independent_bundles(tmp_path: Path) assert len(bundle.answers) == len(bundle.questions) +def test_replay_coalesces_relations_across_consecutive_sparse_samples(tmp_path: Path) -> None: + left = BoundingBox2D(x_min=0.1, y_min=0.2, x_max=0.2, y_max=0.4) + right = BoundingBox2D(x_min=0.7, y_min=0.2, x_max=0.8, y_max=0.4) + observations = tuple( + _observation(frame_id=frame_id, object_id=object_id, box=box) + for frame_id in (0, 150) + for object_id, box in (("left", left), ("right", right)) + ) + + DeterministicObservationBundleGenerator(sample_schedule=((0, 0.0), (150, 15.0))).generate( + observations, tmp_path / "bundle", "1" * 64 + ) + intervals = load_bundle(tmp_path / "bundle").relation_intervals + + assert len(intervals) == 2 + assert all(interval.start_frame_id == 0 for interval in intervals) + assert all(interval.end_frame_id == 150 for interval in intervals) + assert all(interval.evidence_frame_ids == (0, 150) for interval in intervals) + + +def test_replay_does_not_bridge_an_empty_intervening_sample(tmp_path: Path) -> None: + left = BoundingBox2D(x_min=0.1, y_min=0.2, x_max=0.2, y_max=0.4) + right = BoundingBox2D(x_min=0.7, y_min=0.2, x_max=0.8, y_max=0.4) + observations = tuple( + _observation(frame_id=frame_id, object_id=object_id, box=box) + for frame_id in (0, 300) + for object_id, box in (("left", left), ("right", right)) + ) + + DeterministicObservationBundleGenerator( + sample_schedule=((0, 0.0), (150, 15.0), (300, 30.0)) + ).generate(observations, tmp_path / "bundle", "1" * 64) + intervals = load_bundle(tmp_path / "bundle").relation_intervals + + assert len(intervals) == 4 + assert all(interval.start_frame_id == interval.end_frame_id for interval in intervals) + + +def test_replay_observations_accepts_complete_sample_schedule(tmp_path: Path) -> None: + left = BoundingBox2D(x_min=0.1, y_min=0.2, x_max=0.2, y_max=0.4) + right = BoundingBox2D(x_min=0.7, y_min=0.2, x_max=0.8, y_max=0.4) + observations = tuple( + _observation(frame_id=frame_id, object_id=object_id, box=box) + for frame_id in (0, 150) + for object_id, box in (("left", left), ("right", right)) + ) + observations_path = tmp_path / "observations.jsonl" + write_observations(observations_path, observations) + + replay_observations( + observations_path, + tmp_path / "bundle", + "1" * 64, + sample_schedule=((0, 0.0), (150, 15.0)), + ) + + assert len(load_bundle(tmp_path / "bundle").relation_intervals) == 2 + + @pytest.mark.parametrize( ("observations", "expected_code", "expected_guidance"), [ diff --git a/dimos/benchmark/spatiotemporal/test_video_adapter.py b/dimos/benchmark/spatiotemporal/test_video_adapter.py index fd55e5c395..58f7e116f1 100644 --- a/dimos/benchmark/spatiotemporal/test_video_adapter.py +++ b/dimos/benchmark/spatiotemporal/test_video_adapter.py @@ -79,6 +79,25 @@ def close(self) -> None: self.closed = True +class _EmptyDetector(_FakeDetector): + def detect(self, image: Image) -> tuple[DetectedObject, ...]: + self.images.append(image) + return () + + +def test_sampler_preserves_schedule_for_zero_detection_frames( + monkeypatch: pytest.MonkeyPatch, +) -> None: + capture = _FakeCapture([np.zeros((2, 3, 3), dtype=np.uint8) for _ in range(3)]) + detector = _EmptyDetector() + monkeypatch.setattr(cv2, "VideoCapture", lambda _: capture) + + sampler = OpenCVVideoSampler(detector=detector) + + assert sampler.sample(Path("episode.mp4"), episode_id="episode-1") == () + assert sampler.sample_schedule == ((0, 0.0), (1, 0.5), (2, 1.0)) + + def test_sampler_deterministically_normalizes_sampled_frames_and_cleans_up( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/dimos/benchmark/spatiotemporal/video_adapter.py b/dimos/benchmark/spatiotemporal/video_adapter.py index 85d6975f77..218e2a2393 100644 --- a/dimos/benchmark/spatiotemporal/video_adapter.py +++ b/dimos/benchmark/spatiotemporal/video_adapter.py @@ -32,12 +32,14 @@ def __init__(self, detector: ObservationDetector, frame_stride: int = 1) -> None raise ValueError("frame_stride must be positive") self._detector = detector self._frame_stride = frame_stride + self._sample_schedule: tuple[tuple[int, float], ...] = () self._closed = False def sample(self, video_path: Path, episode_id: str) -> tuple[ObjectObservation, ...]: """Return detector observations for every selected source frame.""" capture = cv2.VideoCapture(str(video_path)) observations: list[ObjectObservation] = [] + sample_schedule: list[tuple[int, float]] = [] try: if not capture.isOpened(): raise RuntimeError(f"failed to open video: {video_path}") @@ -58,6 +60,7 @@ def sample(self, video_path: Path, episode_id: str) -> tuple[ObjectObservation, raise ValueError(f"decoded frame {frame_id} must have BGR dimensions") if frame_id % self._frame_stride == 0: timestamp_s = frame_id / fps + sample_schedule.append((frame_id, timestamp_s)) image = Image.from_numpy( cv2.cvtColor(frame, cv2.COLOR_BGR2RGB), format=ImageFormat.RGB, @@ -79,8 +82,14 @@ def sample(self, video_path: Path, episode_id: str) -> tuple[ObjectObservation, frame_id += 1 finally: capture.release() + self._sample_schedule = tuple(sample_schedule) return tuple(observations) + @property + def sample_schedule(self) -> tuple[tuple[int, float], ...]: + """Return every selected frame, including frames with no detections.""" + return self._sample_schedule + def close(self) -> None: """Release the detector owned by this sampler.""" if not self._closed: From fa899c12b8602a99000cb4b6f1a9478d4e3b9538 Mon Sep 17 00:00:00 2001 From: fengweit <2412561+fengweit@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:34:16 +0800 Subject: [PATCH 03/17] fix(benchmark): scope question IDs to episodes --- dimos/benchmark/spatiotemporal/README.md | 26 +------- dimos/benchmark/spatiotemporal/generation.py | 30 ++++----- dimos/benchmark/spatiotemporal/models.py | 21 +++--- .../benchmark/spatiotemporal/test_bundles.py | 29 ++++----- .../spatiotemporal/test_generation.py | 65 +++++++++++++++++-- .../spatiotemporal/test_intervals.py | 18 ++--- dimos/benchmark/spatiotemporal/test_models.py | 29 +++++---- dimos/benchmark/spatiotemporal/test_runner.py | 23 +++---- .../benchmark/spatiotemporal/test_scoring.py | 14 +++- .../test_temporal_memory_answerer.py | 16 ++--- .../spatiotemporal/test_utilities.py | 9 ++- dimos/benchmark/spatiotemporal/utilities.py | 26 +++++++- 12 files changed, 181 insertions(+), 125 deletions(-) diff --git a/dimos/benchmark/spatiotemporal/README.md b/dimos/benchmark/spatiotemporal/README.md index 893561f650..297c807d0b 100644 --- a/dimos/benchmark/spatiotemporal/README.md +++ b/dimos/benchmark/spatiotemporal/README.md @@ -248,31 +248,7 @@ The command fails unless all of these gates hold: ## Reference result -The verified macOS CPU run produced: - -- 25 seconds / 750 decoded frames; -- 5 sampled YOLO-E frames per detector run; -- 2 independent detector/tracker runs with identical results; -- 8 observations; -- 4 native object identities; -- 0 fallback identities; -- 6 relation facts; -- 6 relation intervals; -- 54 public questions: 6 spatial and 48 temporal; -- 54 private answers; -- logical bundle SHA-256 `ba4be04df7ed376925551bf998b6dce3fa08678665644ec0337c6eda6f9d62a9`; -- TemporalMemory readiness after 25 public frames; -- 5 analyzed TemporalMemory windows; -- 3 TemporalMemory state entities; -- 54 valid timestamp-scripted predictions; -- 26 correct, 28 incorrect, 0 missing, and 0 invalid; and -- byte-identical summaries across two complete command invocations. - -Reference full-summary SHA-256: - -```text -610c4bba46705d2ba61240e904faed732199c2fe75d8bb8be86d36a7463bf491 -``` +Schema v2 makes question IDs episode-scoped, so v1 artifact and summary hashes are intentionally incompatible. Run the one-command demo to generate a fresh local reference; the final acceptance section records counts and digests only after all schema-v2 feature work is complete. ## Verification commands diff --git a/dimos/benchmark/spatiotemporal/generation.py b/dimos/benchmark/spatiotemporal/generation.py index 860b004013..633af2e187 100644 --- a/dimos/benchmark/spatiotemporal/generation.py +++ b/dimos/benchmark/spatiotemporal/generation.py @@ -24,7 +24,7 @@ RelationInterval, TemporalPredicate, ) -from dimos.benchmark.spatiotemporal.utilities import SCHEMA_VERSION, stable_id +from dimos.benchmark.spatiotemporal.utilities import stable_question_id def generate_spatial_questions(facts: Sequence[RelationFact]) -> tuple[Question, ...]: @@ -35,15 +35,11 @@ def generate_spatial_questions(facts: Sequence[RelationFact]) -> tuple[Question, questions: dict[str, Question] = {} for fact in facts: object_ids = (fact.subject_id, fact.object_id) - question_id = stable_id( - "question", - { - "object_ids": object_ids, - "predicate": fact.predicate.value, - "question_kind": QuestionKind.SPATIAL.value, - "reference_ids": (), - "schema_version": SCHEMA_VERSION, - }, + question_id = stable_question_id( + episode_id=fact.episode_id, + object_ids=object_ids, + predicate=fact.predicate.value, + question_kind=QuestionKind.SPATIAL.value, ) questions.setdefault( question_id, @@ -126,15 +122,11 @@ def generate_temporal_question_cases( False, ), ): - question_id = stable_id( - "question", - { - "object_ids": (), - "predicate": predicate.value, - "question_kind": QuestionKind.TEMPORAL.value, - "reference_ids": references, - "schema_version": SCHEMA_VERSION, - }, + question_id = stable_question_id( + episode_id=first.episode_id, + predicate=predicate.value, + question_kind=QuestionKind.TEMPORAL.value, + reference_ids=references, ) question = Question( question_id=question_id, diff --git a/dimos/benchmark/spatiotemporal/models.py b/dimos/benchmark/spatiotemporal/models.py index 841542219b..d5dad60db4 100644 --- a/dimos/benchmark/spatiotemporal/models.py +++ b/dimos/benchmark/spatiotemporal/models.py @@ -21,7 +21,11 @@ from pydantic import AfterValidator, BaseModel, ConfigDict, Field, model_validator -from dimos.benchmark.spatiotemporal.utilities import SCHEMA_VERSION, stable_id +from dimos.benchmark.spatiotemporal.utilities import ( + SCHEMA_VERSION, + stable_id, + stable_question_id, +) def _require_nfc(value: str) -> str: @@ -250,15 +254,12 @@ def validate_executable_contract(self) -> Self: raise ValueError("temporal questions cannot contain object IDs") if len(self.reference_ids) != 2 or self.reference_ids[0] == self.reference_ids[1]: raise ValueError("temporal question relation references must differ") - expected_id = stable_id( - "question", - { - "object_ids": self.object_ids, - "predicate": self.predicate.value, - "question_kind": self.question_kind.value, - "reference_ids": self.reference_ids, - "schema_version": SCHEMA_VERSION, - }, + expected_id = stable_question_id( + episode_id=self.episode_id, + object_ids=self.object_ids, + predicate=self.predicate.value, + question_kind=self.question_kind.value, + reference_ids=self.reference_ids, ) if self.question_id != expected_id: raise ValueError("question ID does not match its executable contract") diff --git a/dimos/benchmark/spatiotemporal/test_bundles.py b/dimos/benchmark/spatiotemporal/test_bundles.py index f6bb99f840..f3a81a2dde 100644 --- a/dimos/benchmark/spatiotemporal/test_bundles.py +++ b/dimos/benchmark/spatiotemporal/test_bundles.py @@ -38,6 +38,7 @@ JsonValue, canonical_model_json, stable_id, + stable_question_id, ) @@ -59,15 +60,11 @@ def _spatial_records() -> tuple[ "schema_version": SCHEMA_VERSION, }, ) - question_id = stable_id( - "question", - { - "object_ids": (subject_id, object_id), - "predicate": predicate.value, - "question_kind": QuestionKind.SPATIAL.value, - "reference_ids": (), - "schema_version": SCHEMA_VERSION, - }, + question_id = stable_question_id( + episode_id=episode_id, + object_ids=(subject_id, object_id), + predicate=predicate.value, + question_kind=QuestionKind.SPATIAL.value, ) question = Question( question_id=question_id, @@ -255,15 +252,11 @@ def test_rejects_duplicate_oracle_answer_references(tmp_path: Path) -> None: def test_rejects_temporal_questions_with_foreign_relation_references(tmp_path: Path) -> None: _, observation, fact, _ = _spatial_records() references = (fact.relation_id, f"relation_{'0' * 64}") - question_id = stable_id( - "question", - { - "object_ids": (), - "predicate": TemporalPredicate.BEFORE.value, - "question_kind": QuestionKind.TEMPORAL.value, - "reference_ids": references, - "schema_version": SCHEMA_VERSION, - }, + question_id = stable_question_id( + episode_id="episode_1", + predicate=TemporalPredicate.BEFORE.value, + question_kind=QuestionKind.TEMPORAL.value, + reference_ids=references, ) question = Question( question_id=question_id, diff --git a/dimos/benchmark/spatiotemporal/test_generation.py b/dimos/benchmark/spatiotemporal/test_generation.py index d1b9ef8d1c..e9b3601a5d 100644 --- a/dimos/benchmark/spatiotemporal/test_generation.py +++ b/dimos/benchmark/spatiotemporal/test_generation.py @@ -28,7 +28,11 @@ SpatialPredicate, TemporalPredicate, ) -from dimos.benchmark.spatiotemporal.utilities import SCHEMA_VERSION, JsonValue, stable_id +from dimos.benchmark.spatiotemporal.utilities import ( + SCHEMA_VERSION, + JsonValue, + stable_id, +) def _interval( @@ -39,6 +43,7 @@ def _interval( end_frame_id: int, start_timestamp_s: float | None = None, end_timestamp_s: float | None = None, + episode_id: str = "episode_1", ) -> RelationInterval: if start_timestamp_s is None: start_timestamp_s = start_frame_id / 10 @@ -55,7 +60,7 @@ def _interval( contract: dict[str, JsonValue] = { "end_frame_id": end_frame_id, "end_timestamp_s": end_timestamp_s, - "episode_id": "episode_1", + "episode_id": episode_id, "object_id": object_id, "predicate": predicate.value, "relation_id": relation_id, @@ -67,7 +72,7 @@ def _interval( return RelationInterval( interval_id=stable_id("interval", contract), relation_id=relation_id, - episode_id="episode_1", + episode_id=episode_id, subject_id=subject_id, predicate=predicate, object_id=object_id, @@ -108,7 +113,7 @@ def test_generates_one_public_spatial_question_per_accepted_relation() -> None: question = questions[0] assert ( question.question_id - == "question_4ef1579875e841ec7602de2e9cfc95f0bfdfb5e8d2e38ca798f462f8f72eda1e" + == "question_6c4562bfb3f21f38d6086b9331af250ebd473ce9162af6bf6cb8a28d94978f83" ) assert question.text == "Is obj_red left of obj_blue?" assert question.object_ids == ("obj_red", "obj_blue") @@ -125,6 +130,32 @@ def test_generates_one_public_spatial_question_per_accepted_relation() -> None: ) +def test_spatial_question_ids_are_scoped_to_episode() -> None: + relation_id = stable_id( + "relation", + { + "object_ids": ("obj_red", "obj_blue"), + "predicate": SpatialPredicate.LEFT_OF.value, + "schema_version": SCHEMA_VERSION, + }, + ) + fact = RelationFact( + relation_id=relation_id, + episode_id="episode_1", + frame_id=12, + timestamp_s=0.5, + subject_id="obj_red", + predicate=SpatialPredicate.LEFT_OF, + object_id="obj_blue", + evidence_frame_ids=(12,), + ) + + first = generate_spatial_questions((fact,))[0] + second = generate_spatial_questions((fact.model_copy(update={"episode_id": "episode_2"}),))[0] + + assert first.question_id != second.question_id + + def test_rejects_facts_from_multiple_episodes() -> None: relation_id = stable_id( "relation", @@ -181,6 +212,32 @@ def test_generates_balanced_temporal_questions_in_byte_stable_order() -> None: } +def test_temporal_question_ids_are_scoped_to_episode() -> None: + def question_ids(episode_id: str) -> set[str]: + first = _interval( + "obj_red", + SpatialPredicate.LEFT_OF, + "obj_blue", + 1, + 2, + episode_id=episode_id, + ) + second = _interval( + "obj_green", + SpatialPredicate.ABOVE, + "obj_yellow", + 4, + 5, + episode_id=episode_id, + ) + return { + question.question_id + for question, _ in generate_temporal_question_cases((first, second)) + } + + assert question_ids("episode_1").isdisjoint(question_ids("episode_2")) + + def test_omits_temporal_questions_with_bidirectional_interval_proofs() -> None: relation_a_early = _interval("obj_red", SpatialPredicate.LEFT_OF, "obj_blue", 1, 2) relation_b_early = _interval("obj_green", SpatialPredicate.ABOVE, "obj_yellow", 4, 5) diff --git a/dimos/benchmark/spatiotemporal/test_intervals.py b/dimos/benchmark/spatiotemporal/test_intervals.py index 4bc99463eb..94ac58760a 100644 --- a/dimos/benchmark/spatiotemporal/test_intervals.py +++ b/dimos/benchmark/spatiotemporal/test_intervals.py @@ -116,15 +116,6 @@ def test_coalesces_only_consecutive_samples_with_stable_relation_identity() -> N ) for interval in intervals ] == [ - ( - "episode_1", - "lamp_1", - SpatialPredicate.ABOVE, - "table_1", - 2, - 2, - (2,), - ), ( "episode_1", "mug_1", @@ -143,6 +134,15 @@ def test_coalesces_only_consecutive_samples_with_stable_relation_identity() -> N 4, (4,), ), + ( + "episode_1", + "lamp_1", + SpatialPredicate.ABOVE, + "table_1", + 2, + 2, + (2,), + ), ( "episode_2", "mug_1", diff --git a/dimos/benchmark/spatiotemporal/test_models.py b/dimos/benchmark/spatiotemporal/test_models.py index 220ee49065..063ae5c731 100644 --- a/dimos/benchmark/spatiotemporal/test_models.py +++ b/dimos/benchmark/spatiotemporal/test_models.py @@ -32,7 +32,11 @@ SpatialPredicate, TemporalPredicate, ) -from dimos.benchmark.spatiotemporal.utilities import SCHEMA_VERSION, stable_id +from dimos.benchmark.spatiotemporal.utilities import ( + SCHEMA_VERSION, + stable_id, + stable_question_id, +) def test_bounding_box_accepts_only_strict_normalized_valid_bounds() -> None: @@ -117,7 +121,12 @@ def test_object_observation_allows_finite_negative_timestamp_translation() -> No def test_question_rejects_malformed_id_and_non_nfc_object_identity() -> None: payload = { - "question_id": "question_4ef1579875e841ec7602de2e9cfc95f0bfdfb5e8d2e38ca798f462f8f72eda1e", + "question_id": stable_question_id( + episode_id="episode_1", + object_ids=("obj_red", "obj_blue"), + predicate=SpatialPredicate.LEFT_OF.value, + question_kind=QuestionKind.SPATIAL.value, + ), "episode_id": "episode_1", "text": "Is the mug left of the laptop at the end?", "question_kind": QuestionKind.SPATIAL, @@ -157,7 +166,7 @@ def test_relation_fact_has_stable_private_sample_identity() -> None: assert ( fact.relation_id - == "relation_50c38f99a34ced799c3b8c8bd3417ac288a6ce9481dc9de19525042a2e4f5ea3" + == "relation_d4350b93518e2cc7628cc560706c72ea137ce4ba887f4bced0d6a9513d0319b3" ) with pytest.raises(ValidationError, match="relation ID"): RelationFact.model_validate(fact.model_dump() | {"relation_id": f"relation_{'0' * 64}"}) @@ -231,15 +240,11 @@ def test_relation_interval_has_strict_consistent_bounds() -> None: def test_question_variants_are_disjoint_and_have_stable_ids() -> None: references = (f"relation_{'1' * 64}", f"relation_{'2' * 64}") - question_id = stable_id( - "question", - { - "object_ids": (), - "predicate": TemporalPredicate.BEFORE.value, - "question_kind": QuestionKind.TEMPORAL.value, - "reference_ids": references, - "schema_version": SCHEMA_VERSION, - }, + question_id = stable_question_id( + episode_id="episode_1", + predicate=TemporalPredicate.BEFORE.value, + question_kind=QuestionKind.TEMPORAL.value, + reference_ids=references, ) temporal = Question( question_id=question_id, diff --git a/dimos/benchmark/spatiotemporal/test_runner.py b/dimos/benchmark/spatiotemporal/test_runner.py index b35c83d312..15585cb3a8 100644 --- a/dimos/benchmark/spatiotemporal/test_runner.py +++ b/dimos/benchmark/spatiotemporal/test_runner.py @@ -27,9 +27,14 @@ build_evaluation_report, parse_candidate_prediction, ) -from dimos.benchmark.spatiotemporal.utilities import SCHEMA_VERSION, stable_id +from dimos.benchmark.spatiotemporal.utilities import stable_question_id -QUESTION_ID = "question_4ef1579875e841ec7602de2e9cfc95f0bfdfb5e8d2e38ca798f462f8f72eda1e" +QUESTION_ID = stable_question_id( + episode_id="episode_1", + object_ids=("obj_red", "obj_blue"), + predicate=SpatialPredicate.LEFT_OF.value, + question_kind=QuestionKind.SPATIAL.value, +) @pytest.mark.parametrize( @@ -64,15 +69,11 @@ def test_parses_only_boolean_and_explicit_yes_no_answers( def test_builds_evidence_linked_aggregate_report_with_filtered_diagnostics() -> None: questions = tuple( Question( - question_id=stable_id( - "question", - { - "object_ids": ("obj_red", "obj_blue"), - "predicate": predicate.value, - "question_kind": QuestionKind.SPATIAL.value, - "reference_ids": (), - "schema_version": SCHEMA_VERSION, - }, + question_id=stable_question_id( + episode_id="episode_1", + object_ids=("obj_red", "obj_blue"), + predicate=predicate.value, + question_kind=QuestionKind.SPATIAL.value, ), episode_id="episode_1", text=f"Is the red object {predicate.value} the blue object?", diff --git a/dimos/benchmark/spatiotemporal/test_scoring.py b/dimos/benchmark/spatiotemporal/test_scoring.py index df3597f88e..c47e886065 100644 --- a/dimos/benchmark/spatiotemporal/test_scoring.py +++ b/dimos/benchmark/spatiotemporal/test_scoring.py @@ -24,11 +24,19 @@ SpatialPredicate, ) from dimos.benchmark.spatiotemporal.scoring import score_prediction +from dimos.benchmark.spatiotemporal.utilities import stable_question_id + +QUESTION_ID = stable_question_id( + episode_id="episode_1", + object_ids=("obj_red", "obj_blue"), + predicate=SpatialPredicate.LEFT_OF.value, + question_kind=QuestionKind.SPATIAL.value, +) def test_scores_exact_boolean_prediction_with_separate_oracle() -> None: question = Question( - question_id="question_4ef1579875e841ec7602de2e9cfc95f0bfdfb5e8d2e38ca798f462f8f72eda1e", + question_id=QUESTION_ID, episode_id="episode_1", text="Is the mug left of the laptop at the end?", question_kind=QuestionKind.SPATIAL, @@ -52,7 +60,7 @@ def test_scores_exact_boolean_prediction_with_separate_oracle() -> None: def test_scores_always_yes_incorrect_against_false_private_truth() -> None: question = Question( - question_id="question_4ef1579875e841ec7602de2e9cfc95f0bfdfb5e8d2e38ca798f462f8f72eda1e", + question_id=QUESTION_ID, episode_id="episode_1", text="Is the mug left of the laptop at the end?", question_kind=QuestionKind.SPATIAL, @@ -75,7 +83,7 @@ def test_scores_always_yes_incorrect_against_false_private_truth() -> None: def test_rejects_oracle_or_prediction_for_another_question() -> None: - question_id = "question_4ef1579875e841ec7602de2e9cfc95f0bfdfb5e8d2e38ca798f462f8f72eda1e" + question_id = QUESTION_ID other_id = f"question_{'0' * 64}" question = Question( question_id=question_id, diff --git a/dimos/benchmark/spatiotemporal/test_temporal_memory_answerer.py b/dimos/benchmark/spatiotemporal/test_temporal_memory_answerer.py index 8ddf19e03c..889373b026 100644 --- a/dimos/benchmark/spatiotemporal/test_temporal_memory_answerer.py +++ b/dimos/benchmark/spatiotemporal/test_temporal_memory_answerer.py @@ -20,7 +20,7 @@ from dimos.benchmark.spatiotemporal.models import Question, QuestionKind, SpatialPredicate from dimos.benchmark.spatiotemporal.temporal_memory_answerer import TemporalMemoryAnswerer -from dimos.benchmark.spatiotemporal.utilities import SCHEMA_VERSION, stable_id +from dimos.benchmark.spatiotemporal.utilities import stable_question_id def test_temporal_memory_answerer_enforces_public_candidate_lifecycle(tmp_path: Path) -> None: @@ -39,15 +39,11 @@ def cleanup() -> None: answerer = TemporalMemoryAnswerer(ingest=ingest, query=query, cleanup=cleanup) question = Question( - question_id=stable_id( - "question", - { - "object_ids": ("obj_red", "obj_blue"), - "predicate": SpatialPredicate.LEFT_OF.value, - "question_kind": QuestionKind.SPATIAL.value, - "reference_ids": (), - "schema_version": SCHEMA_VERSION, - }, + question_id=stable_question_id( + episode_id="episode_1", + object_ids=("obj_red", "obj_blue"), + predicate=SpatialPredicate.LEFT_OF.value, + question_kind=QuestionKind.SPATIAL.value, ), episode_id="episode_1", text="Is the red object left of the blue object?", diff --git a/dimos/benchmark/spatiotemporal/test_utilities.py b/dimos/benchmark/spatiotemporal/test_utilities.py index 5915ae31f2..049b302833 100644 --- a/dimos/benchmark/spatiotemporal/test_utilities.py +++ b/dimos/benchmark/spatiotemporal/test_utilities.py @@ -17,12 +17,17 @@ import json from dimos.benchmark.spatiotemporal.models import Question, QuestionKind, SpatialPredicate -from dimos.benchmark.spatiotemporal.utilities import canonical_model_json +from dimos.benchmark.spatiotemporal.utilities import canonical_model_json, stable_question_id def test_serializes_strict_records_to_identical_canonical_json() -> None: question = Question( - question_id="question_4ef1579875e841ec7602de2e9cfc95f0bfdfb5e8d2e38ca798f462f8f72eda1e", + question_id=stable_question_id( + episode_id="episode_1", + object_ids=("obj_red", "obj_blue"), + predicate=SpatialPredicate.LEFT_OF.value, + question_kind=QuestionKind.SPATIAL.value, + ), episode_id="episode_1", text="Is the mug left of the laptop at the end?", question_kind=QuestionKind.SPATIAL, diff --git a/dimos/benchmark/spatiotemporal/utilities.py b/dimos/benchmark/spatiotemporal/utilities.py index 5e420b5793..537276905a 100644 --- a/dimos/benchmark/spatiotemporal/utilities.py +++ b/dimos/benchmark/spatiotemporal/utilities.py @@ -24,8 +24,8 @@ JsonValue: TypeAlias = ( str | bool | None | int | float | Mapping[str, "JsonValue"] | Sequence["JsonValue"] ) -SchemaVersion: TypeAlias = Literal["spatiotemporal-video-qa/v1"] -SCHEMA_VERSION: Final[SchemaVersion] = "spatiotemporal-video-qa/v1" +SchemaVersion: TypeAlias = Literal["spatiotemporal-video-qa/v2"] +SCHEMA_VERSION: Final[SchemaVersion] = "spatiotemporal-video-qa/v2" def canonical_json_bytes(value: JsonValue) -> bytes: @@ -43,6 +43,28 @@ def stable_id(prefix: str, preimage: JsonValue) -> str: return f"{prefix}_{sha256(canonical_json_bytes(preimage)).hexdigest()}" +def stable_question_id( + *, + episode_id: str, + question_kind: str, + predicate: str, + object_ids: Sequence[str] = (), + reference_ids: Sequence[str] = (), +) -> str: + """Create an episode-scoped ID from answer-free public semantics.""" + return stable_id( + "question", + { + "episode_id": episode_id, + "object_ids": tuple(object_ids), + "predicate": predicate, + "question_kind": question_kind, + "reference_ids": tuple(reference_ids), + "schema_version": SCHEMA_VERSION, + }, + ) + + def canonical_model_json(model: BaseModel) -> str: """Serialize one strict record to deterministic canonical JSON.""" return canonical_json_bytes(model.model_dump(mode="json")).decode("utf-8") From a4db7b319d547a9e6f0934564f8c61a3d153b18d Mon Sep 17 00:00:00 2001 From: fengweit <2412561+fengweit@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:00:26 +0800 Subject: [PATCH 04/17] feat(benchmark): balance and humanize relation questions --- dimos/benchmark/spatiotemporal/generation.py | 117 ++++++++++++++---- dimos/benchmark/spatiotemporal/replay.py | 46 +++---- .../spatiotemporal/test_generation.py | 80 +++++++++++- dimos/benchmark/spatiotemporal/test_replay.py | 45 ++++++- 4 files changed, 231 insertions(+), 57 deletions(-) diff --git a/dimos/benchmark/spatiotemporal/generation.py b/dimos/benchmark/spatiotemporal/generation.py index 633af2e187..2ffbcf81ed 100644 --- a/dimos/benchmark/spatiotemporal/generation.py +++ b/dimos/benchmark/spatiotemporal/generation.py @@ -22,37 +22,105 @@ QuestionKind, RelationFact, RelationInterval, + SpatialPredicate, TemporalPredicate, ) from dimos.benchmark.spatiotemporal.utilities import stable_question_id +_OPPOSITE_SPATIAL_PREDICATE = { + SpatialPredicate.LEFT_OF: SpatialPredicate.RIGHT_OF, + SpatialPredicate.RIGHT_OF: SpatialPredicate.LEFT_OF, + SpatialPredicate.ABOVE: SpatialPredicate.BELOW, + SpatialPredicate.BELOW: SpatialPredicate.ABOVE, +} -def generate_spatial_questions(facts: Sequence[RelationFact]) -> tuple[Question, ...]: - """Generate one answer-free public question per accepted spatial relation.""" + +def _canonical_spatial_relation( + subject_id: str, predicate: SpatialPredicate, object_id: str +) -> tuple[str, SpatialPredicate, str]: + if predicate is SpatialPredicate.RIGHT_OF: + return object_id, SpatialPredicate.LEFT_OF, subject_id + if predicate is SpatialPredicate.BELOW: + return object_id, SpatialPredicate.ABOVE, subject_id + return subject_id, predicate, object_id + + +def _describe_relation(interval: RelationInterval) -> str: + return ( + f"{interval.subject_id} {interval.predicate.value.replace('-', ' ')} {interval.object_id}" + ) + + +def generate_spatial_question_cases( + facts: Sequence[RelationFact], + *, + sample_frame_ids: Sequence[int] | None = None, +) -> tuple[tuple[Question, OracleAnswer], ...]: + """Generate episode-level positives and only globally absent negative controls.""" if len({fact.episode_id for fact in facts}) > 1: raise ValueError("spatial questions must be generated from one episode") - questions: dict[str, Question] = {} + grouped: dict[tuple[str, str, SpatialPredicate, str], set[int]] = {} for fact in facts: - object_ids = (fact.subject_id, fact.object_id) - question_id = stable_question_id( - episode_id=fact.episode_id, - object_ids=object_ids, - predicate=fact.predicate.value, - question_kind=QuestionKind.SPATIAL.value, + subject_id, predicate, object_id = _canonical_spatial_relation( + fact.subject_id, fact.predicate, fact.object_id ) - questions.setdefault( - question_id, - Question( - question_id=question_id, - episode_id=fact.episode_id, - text=f"Is {fact.subject_id} {fact.predicate.value.replace('-', ' ')} {fact.object_id}?", - question_kind=QuestionKind.SPATIAL, - predicate=fact.predicate, - object_ids=object_ids, - ), + key = (fact.episode_id, subject_id, predicate, object_id) + grouped.setdefault(key, set()).update(fact.evidence_frame_ids) + episode_evidence = tuple( + sorted( + set(sample_frame_ids) + if sample_frame_ids is not None + else {frame_id for evidence in grouped.values() for frame_id in evidence} ) - return tuple(questions[question_id] for question_id in sorted(questions)) + ) + + cases: dict[str, tuple[Question, OracleAnswer]] = {} + for (episode_id, subject_id, accepted_predicate, object_id), evidence in grouped.items(): + question_specs = [(accepted_predicate, True, tuple(sorted(evidence)))] + inverse_key = (episode_id, object_id, accepted_predicate, subject_id) + if inverse_key not in grouped: + question_specs.append( + ( + _OPPOSITE_SPATIAL_PREDICATE[accepted_predicate], + False, + episode_evidence, + ) + ) + for predicate, expected, evidence_frame_ids in question_specs: + object_ids = (subject_id, object_id) + question_id = stable_question_id( + episode_id=episode_id, + object_ids=object_ids, + predicate=predicate.value, + question_kind=QuestionKind.SPATIAL.value, + ) + cases[question_id] = ( + Question( + question_id=question_id, + episode_id=episode_id, + text=( + f"Did {subject_id} ever appear " + f"{predicate.value.replace('-', ' ')} {object_id}?" + ), + question_kind=QuestionKind.SPATIAL, + predicate=predicate, + object_ids=object_ids, + ), + OracleAnswer( + question_id=question_id, + expected=expected, + evidence_frame_ids=evidence_frame_ids, + ), + ) + return tuple(cases[question_id] for question_id in sorted(cases)) + + +def generate_spatial_questions(facts: Sequence[RelationFact]) -> tuple[Question, ...]: + """Generate one answer-free public question per accepted spatial relation.""" + return tuple( + question for question, answer in generate_spatial_question_cases(facts) if answer.expected + ) def generate_temporal_question_cases( @@ -100,6 +168,10 @@ def generate_temporal_question_cases( interval_proofs, key=lambda proof: (proof[0].interval_id, proof[1].interval_id), ) + descriptions = { + first.relation_id: _describe_relation(first), + second.relation_id: _describe_relation(second), + } for predicate, references, expected in ( ( TemporalPredicate.BEFORE, @@ -131,7 +203,10 @@ def generate_temporal_question_cases( question = Question( question_id=question_id, episode_id=first.episode_id, - text=(f"Did {references[0]} happen {predicate.value} {references[1]}?"), + text=( + f'Did "{descriptions[references[0]]}" happen {predicate.value} ' + f'"{descriptions[references[1]]}"?' + ), question_kind=QuestionKind.TEMPORAL, predicate=predicate, object_ids=(), diff --git a/dimos/benchmark/spatiotemporal/replay.py b/dimos/benchmark/spatiotemporal/replay.py index 2faa51ad5c..d023eb88b5 100644 --- a/dimos/benchmark/spatiotemporal/replay.py +++ b/dimos/benchmark/spatiotemporal/replay.py @@ -18,18 +18,15 @@ from collections.abc import Sequence from itertools import permutations from pathlib import Path -from typing import cast from dimos.benchmark.spatiotemporal.bundles import load_bundle, write_bundle from dimos.benchmark.spatiotemporal.generation import ( - generate_spatial_questions, + generate_spatial_question_cases, generate_temporal_question_cases, ) from dimos.benchmark.spatiotemporal.intervals import build_relation_intervals from dimos.benchmark.spatiotemporal.models import ( ObjectObservation, - OracleAnswer, - Question, RelationFact, ) from dimos.benchmark.spatiotemporal.observation_io import read_observations @@ -43,13 +40,11 @@ from dimos.benchmark.spatiotemporal.relations import ( SpatialRelationCandidate, derive_above, - derive_below, derive_left_of, - derive_right_of, ) from dimos.benchmark.spatiotemporal.utilities import SCHEMA_VERSION, stable_id -_RELATION_DERIVERS = (derive_left_of, derive_right_of, derive_above, derive_below) +_RELATION_DERIVERS = (derive_left_of, derive_above) def _relation_facts( @@ -94,25 +89,6 @@ def _relation_fact(candidate: SpatialRelationCandidate) -> RelationFact: ) -def _spatial_answer(question: Question, facts: Sequence[RelationFact]) -> OracleAnswer: - subject_id, object_id = cast("tuple[str, str]", question.object_ids) - return OracleAnswer( - question_id=question.question_id, - expected=True, - evidence_frame_ids=tuple( - sorted( - { - fact.frame_id - for fact in facts - if fact.subject_id == subject_id - and fact.object_id == object_id - and fact.predicate == question.predicate - } - ) - ), - ) - - class DeterministicObservationBundleGenerator: """Generate deterministic bundles from canonical object observations.""" @@ -151,11 +127,19 @@ def generate( "saved observations contain no spatially separated object pairs", ) intervals = build_relation_intervals(facts, sample_schedule=self._sample_schedule) - spatial_questions = generate_spatial_questions(facts) + sample_frame_ids = ( + tuple(frame_id for frame_id, _ in self._sample_schedule) + if self._sample_schedule is not None + else tuple(sorted({observation.frame_id for observation in observations})) + ) + spatial_cases = generate_spatial_question_cases(facts, sample_frame_ids=sample_frame_ids) temporal_cases = generate_temporal_question_cases(intervals) questions = tuple( sorted( - (*spatial_questions, *(question for question, _ in temporal_cases)), + ( + *(question for question, _ in spatial_cases), + *(question for question, _ in temporal_cases), + ), key=lambda question: question.question_id, ) ) @@ -164,10 +148,12 @@ def generate( ReplayInsufficiencyCode.NO_QUESTIONS, "accepted relations produced no evaluation questions; inspect generation inputs", ) - spatial_answers = tuple(_spatial_answer(question, facts) for question in spatial_questions) answers = tuple( sorted( - (*spatial_answers, *(answer for _, answer in temporal_cases)), + ( + *(answer for _, answer in spatial_cases), + *(answer for _, answer in temporal_cases), + ), key=lambda answer: answer.question_id, ) ) diff --git a/dimos/benchmark/spatiotemporal/test_generation.py b/dimos/benchmark/spatiotemporal/test_generation.py index e9b3601a5d..cfd7f2c8fc 100644 --- a/dimos/benchmark/spatiotemporal/test_generation.py +++ b/dimos/benchmark/spatiotemporal/test_generation.py @@ -84,6 +84,73 @@ def _interval( ) +def test_generates_balanced_spatial_question_cases() -> None: + from dimos.benchmark.spatiotemporal.generation import generate_spatial_question_cases + + relation_id = stable_id( + "relation", + { + "object_ids": ("obj_red", "obj_blue"), + "predicate": SpatialPredicate.LEFT_OF.value, + "schema_version": SCHEMA_VERSION, + }, + ) + fact = RelationFact( + relation_id=relation_id, + episode_id="episode_1", + frame_id=12, + timestamp_s=0.5, + subject_id="obj_red", + predicate=SpatialPredicate.LEFT_OF, + object_id="obj_blue", + evidence_frame_ids=(12,), + ) + + cases = generate_spatial_question_cases((fact,)) + + assert len(cases) == 2 + assert Counter(answer.expected for _, answer in cases) == {True: 1, False: 1} + assert {(question.predicate, answer.expected) for question, answer in cases} == { + (SpatialPredicate.LEFT_OF, True), + (SpatialPredicate.RIGHT_OF, False), + } + assert all(answer.evidence_frame_ids == (12,) for _, answer in cases) + + +def test_spatial_cases_are_order_independent_when_relation_flips() -> None: + def fact(predicate: SpatialPredicate, frame_id: int) -> RelationFact: + return RelationFact( + relation_id=stable_id( + "relation", + { + "object_ids": ("obj_a", "obj_b"), + "predicate": predicate.value, + "schema_version": SCHEMA_VERSION, + }, + ), + episode_id="episode_1", + frame_id=frame_id, + timestamp_s=frame_id / 10, + subject_id="obj_a", + predicate=predicate, + object_id="obj_b", + evidence_frame_ids=(frame_id,), + ) + + from dimos.benchmark.spatiotemporal.generation import generate_spatial_question_cases + + facts = (fact(SpatialPredicate.LEFT_OF, 1), fact(SpatialPredicate.RIGHT_OF, 2)) + forward = generate_spatial_question_cases(facts) + backward = generate_spatial_question_cases(tuple(reversed(facts))) + + assert tuple( + (question.model_dump_json(), answer.model_dump_json()) for question, answer in forward + ) == tuple( + (question.model_dump_json(), answer.model_dump_json()) for question, answer in backward + ) + assert Counter(answer.expected for _, answer in forward) == {True: 2} + + def test_generates_one_public_spatial_question_per_accepted_relation() -> None: relation_id = stable_id( "relation", @@ -115,7 +182,7 @@ def test_generates_one_public_spatial_question_per_accepted_relation() -> None: question.question_id == "question_6c4562bfb3f21f38d6086b9331af250ebd473ce9162af6bf6cb8a28d94978f83" ) - assert question.text == "Is obj_red left of obj_blue?" + assert question.text == "Did obj_red ever appear left of obj_blue?" assert question.object_ids == ("obj_red", "obj_blue") public_record = question.model_dump(mode="json") assert ( @@ -212,6 +279,17 @@ def test_generates_balanced_temporal_questions_in_byte_stable_order() -> None: } +def test_temporal_questions_use_human_readable_relation_descriptions() -> None: + first = _interval("obj_red", SpatialPredicate.LEFT_OF, "obj_blue", 1, 2) + second = _interval("obj_green", SpatialPredicate.ABOVE, "obj_yellow", 4, 5) + + questions = tuple(question for question, _ in generate_temporal_question_cases((first, second))) + + assert all("relation_" not in question.text for question in questions) + assert all("obj_red left of obj_blue" in question.text for question in questions) + assert all("obj_green above obj_yellow" in question.text for question in questions) + + def test_temporal_question_ids_are_scoped_to_episode() -> None: def question_ids(episode_id: str) -> set[str]: first = _interval( diff --git a/dimos/benchmark/spatiotemporal/test_replay.py b/dimos/benchmark/spatiotemporal/test_replay.py index 744dc48524..becb0fa240 100644 --- a/dimos/benchmark/spatiotemporal/test_replay.py +++ b/dimos/benchmark/spatiotemporal/test_replay.py @@ -14,13 +14,18 @@ """Behavioral tests for deterministic observation replay.""" +from collections import Counter from pathlib import Path import pytest from dimos.benchmark.spatiotemporal import replay from dimos.benchmark.spatiotemporal.bundles import load_bundle -from dimos.benchmark.spatiotemporal.models import BoundingBox2D, ObjectObservation +from dimos.benchmark.spatiotemporal.models import ( + BoundingBox2D, + ObjectObservation, + QuestionKind, +) from dimos.benchmark.spatiotemporal.observation_io import read_observations, write_observations from dimos.benchmark.spatiotemporal.ports import ( ReplayInsufficiencyCode, @@ -107,12 +112,42 @@ def test_replay_coalesces_relations_across_consecutive_sparse_samples(tmp_path: ) intervals = load_bundle(tmp_path / "bundle").relation_intervals - assert len(intervals) == 2 + assert len(intervals) == 1 assert all(interval.start_frame_id == 0 for interval in intervals) assert all(interval.end_frame_id == 150 for interval in intervals) assert all(interval.evidence_frame_ids == (0, 150) for interval in intervals) +def test_replay_writes_balanced_spatial_oracles(tmp_path: Path) -> None: + observations = ( + _observation( + frame_id=1, + object_id="left", + box=BoundingBox2D(x_min=0.1, y_min=0.2, x_max=0.2, y_max=0.4), + ), + _observation( + frame_id=1, + object_id="right", + box=BoundingBox2D(x_min=0.7, y_min=0.2, x_max=0.8, y_max=0.4), + ), + ) + + DeterministicObservationBundleGenerator().generate(observations, tmp_path / "bundle", "1" * 64) + bundle = load_bundle(tmp_path / "bundle") + spatial_ids = { + question.question_id + for question in bundle.questions + if question.question_kind is QuestionKind.SPATIAL + } + + assert len(bundle.relation_facts) == 1 + assert bundle.relation_facts[0].predicate.value == "left-of" + assert len(spatial_ids) == 2 + assert Counter( + answer.expected for answer in bundle.answers if answer.question_id in spatial_ids + ) == {True: 1, False: 1} + + def test_replay_does_not_bridge_an_empty_intervening_sample(tmp_path: Path) -> None: left = BoundingBox2D(x_min=0.1, y_min=0.2, x_max=0.2, y_max=0.4) right = BoundingBox2D(x_min=0.7, y_min=0.2, x_max=0.8, y_max=0.4) @@ -127,7 +162,7 @@ def test_replay_does_not_bridge_an_empty_intervening_sample(tmp_path: Path) -> N ).generate(observations, tmp_path / "bundle", "1" * 64) intervals = load_bundle(tmp_path / "bundle").relation_intervals - assert len(intervals) == 4 + assert len(intervals) == 2 assert all(interval.start_frame_id == interval.end_frame_id for interval in intervals) @@ -149,7 +184,7 @@ def test_replay_observations_accepts_complete_sample_schedule(tmp_path: Path) -> sample_schedule=((0, 0.0), (150, 15.0)), ) - assert len(load_bundle(tmp_path / "bundle").relation_intervals) == 2 + assert len(load_bundle(tmp_path / "bundle").relation_intervals) == 1 @pytest.mark.parametrize( @@ -216,7 +251,7 @@ def test_reports_when_accepted_relations_produce_no_questions( box=BoundingBox2D(x_min=0.7, y_min=0.1, x_max=0.8, y_max=0.2), ), ) - monkeypatch.setattr(replay, "generate_spatial_questions", lambda facts: ()) + monkeypatch.setattr(replay, "generate_spatial_question_cases", lambda facts, **kwargs: ()) with pytest.raises(ReplayInsufficiencyError) as raised: DeterministicObservationBundleGenerator().generate( From 650cbeae64d7ff67e82363de558a64ea7a5fb9c4 Mon Sep 17 00:00:00 2001 From: fengweit <2412561+fengweit@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:18:48 +0800 Subject: [PATCH 05/17] feat(benchmark): add private evidence viewer --- dimos/benchmark/spatiotemporal/demo.py | 11 + .../spatiotemporal/evidence_viewer.py | 301 ++++++++++++++++++ dimos/benchmark/spatiotemporal/test_demo.py | 44 +++ .../spatiotemporal/test_evidence_viewer.py | 251 +++++++++++++++ 4 files changed, 607 insertions(+) create mode 100644 dimos/benchmark/spatiotemporal/evidence_viewer.py create mode 100644 dimos/benchmark/spatiotemporal/test_evidence_viewer.py diff --git a/dimos/benchmark/spatiotemporal/demo.py b/dimos/benchmark/spatiotemporal/demo.py index c37d75c0dc..2cd6d2277e 100644 --- a/dimos/benchmark/spatiotemporal/demo.py +++ b/dimos/benchmark/spatiotemporal/demo.py @@ -32,6 +32,7 @@ import cv2 from dimos.benchmark.spatiotemporal.bundles import EvaluationBundle, load_bundle +from dimos.benchmark.spatiotemporal.evidence_viewer import write_evidence_viewer from dimos.benchmark.spatiotemporal.models import ObjectObservation, Question, QuestionId from dimos.benchmark.spatiotemporal.observation_io import write_observations from dimos.benchmark.spatiotemporal.replay import replay_observations @@ -431,8 +432,18 @@ def run_demo( duration_s, ) candidate = _score_candidate(bundle, predictions, runtime) + viewer_result = write_evidence_viewer( + video, + bundle, + _safe_child(output_root, "evidence-viewer"), + ) summary = { "candidate": candidate, + "review": { + "evidence_frame_count": viewer_result.evidence_frame_count, + "index_path": f"evidence-viewer/{viewer_result.index_path}", + "question_count": viewer_result.question_count, + }, "teacher": teacher, "video": { "duration_s": duration_s, diff --git a/dimos/benchmark/spatiotemporal/evidence_viewer.py b/dimos/benchmark/spatiotemporal/evidence_viewer.py new file mode 100644 index 0000000000..44fd5005ab --- /dev/null +++ b/dimos/benchmark/spatiotemporal/evidence_viewer.py @@ -0,0 +1,301 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Evaluator-only HTML and annotated-frame evidence viewer.""" + +from collections import defaultdict +from dataclasses import dataclass +from hashlib import sha256 +from html import escape +from pathlib import Path +import shutil +from tempfile import mkdtemp +from typing import Protocol + +import cv2 +import numpy as np + +from dimos.benchmark.spatiotemporal.models import ( + ObjectObservation, + OracleAnswer, + Question, + RelationInterval, +) + + +class EvidenceBundle(Protocol): + """Private evaluator records required to render evidence.""" + + @property + def questions(self) -> tuple[Question, ...]: ... + + @property + def answers(self) -> tuple[OracleAnswer, ...]: ... + + @property + def observations(self) -> tuple[ObjectObservation, ...]: ... + + @property + def relation_intervals(self) -> tuple[RelationInterval, ...]: ... + + +@dataclass(frozen=True) +class EvidenceViewerResult: + """Root-independent description of generated viewer artifacts.""" + + index_path: str + evidence_frame_count: int + question_count: int + + +def _assert_no_symlink_components(path: Path) -> None: + absolute = path.absolute() + for component in reversed((absolute, *absolute.parents)): + if component.is_symlink(): + raise ValueError(f"viewer path contains a symlink: {component}") + + +def _validate_output_root(root: Path) -> None: + _assert_no_symlink_components(root) + if root.exists() and not root.is_dir(): + raise ValueError(f"viewer output must be a directory: {root}") + if root.exists(): + for path in root.rglob("*"): + if path.is_symlink(): + raise ValueError(f"viewer output contains a symlink: {path}") + + +def _create_staging_root(root: Path) -> Path: + parent = root.parent + _assert_no_symlink_components(parent) + parent.mkdir(parents=True, exist_ok=True) + _assert_no_symlink_components(parent) + staging = Path(mkdtemp(prefix=f".{root.name}.tmp-", dir=parent)) + (staging / "frames").mkdir() + return staging + + +def _replace_output_atomically(staging: Path, output_root: Path) -> None: + backup: Path | None = None + if output_root.exists(): + backup = Path(mkdtemp(prefix=f".{output_root.name}.old-", dir=output_root.parent)) + backup.rmdir() + output_root.rename(backup) + try: + staging.rename(output_root) + except Exception: + if backup is not None and backup.exists() and not output_root.exists(): + backup.rename(output_root) + raise + if backup is not None: + shutil.rmtree(backup) + + +def _frame_evidence(bundle: EvidenceBundle) -> tuple[dict[str, tuple[int, ...]], tuple[int, ...]]: + intervals = {interval.interval_id: interval for interval in bundle.relation_intervals} + evidence_by_question: dict[str, tuple[int, ...]] = {} + all_frames: set[int] = set() + for answer in bundle.answers: + frames = set(answer.evidence_frame_ids) + for interval_id in answer.evidence_interval_ids: + try: + frames.update(intervals[interval_id].evidence_frame_ids) + except KeyError as error: + raise ValueError(f"unknown evidence interval: {interval_id}") from error + ordered = tuple(sorted(frames)) + evidence_by_question[answer.question_id] = ordered + all_frames.update(ordered) + return evidence_by_question, tuple(sorted(all_frames)) + + +def _object_color(object_id: str) -> tuple[int, int, int]: + digest = sha256(object_id.encode("utf-8")).digest() + return tuple(64 + component % 160 for component in digest[:3]) # type: ignore[return-value] + + +def _annotate_frame(frame: np.ndarray, observations: tuple[ObjectObservation, ...]) -> np.ndarray: + annotated = frame.copy() + height, width = annotated.shape[:2] + for observation in sorted(observations, key=lambda item: item.object_id): + x_min = min(width - 1, max(0, round(observation.box.x_min * width))) + y_min = min(height - 1, max(0, round(observation.box.y_min * height))) + x_max = min(width - 1, max(0, round(observation.box.x_max * width))) + y_max = min(height - 1, max(0, round(observation.box.y_max * height))) + color = _object_color(observation.object_id) + cv2.rectangle(annotated, (x_min, y_min), (x_max, y_max), color, 2) + label = f"{observation.object_id}: {observation.label} {observation.confidence:.2f}" + cv2.putText( + annotated, + label, + (x_min, max(12, y_min - 4)), + cv2.FONT_HERSHEY_SIMPLEX, + 0.4, + color, + 1, + cv2.LINE_AA, + ) + return annotated + + +def _write_evidence_frames( + video_path: Path, + output_root: Path, + frame_ids: tuple[int, ...], + observations: tuple[ObjectObservation, ...], +) -> None: + if not frame_ids: + return + observations_by_frame: dict[int, list[ObjectObservation]] = defaultdict(list) + for observation in observations: + observations_by_frame[observation.frame_id].append(observation) + + wanted = set(frame_ids) + capture = cv2.VideoCapture(str(video_path)) + written: set[int] = set() + try: + if not capture.isOpened(): + raise RuntimeError(f"failed to open evidence video: {video_path}") + frame_id = 0 + while frame_id <= frame_ids[-1]: + decoded, frame = capture.read() + if not decoded or frame is None: + raise RuntimeError(f"failed to decode evidence frame {frame_id}") + if frame_id in wanted: + annotated = _annotate_frame(frame, tuple(observations_by_frame.get(frame_id, ()))) + encoded, content = cv2.imencode(".jpg", annotated, [cv2.IMWRITE_JPEG_QUALITY, 90]) + if not encoded: + raise RuntimeError(f"failed to encode evidence frame {frame_id}") + (output_root / "frames" / f"frame_{frame_id:06d}.jpg").write_bytes( + content.tobytes() + ) + written.add(frame_id) + frame_id += 1 + finally: + capture.release() + if written != wanted: + missing = sorted(wanted - written) + raise RuntimeError(f"missing evidence frames: {missing}") + + +def _render_html( + bundle: EvidenceBundle, + evidence_by_question: dict[str, tuple[int, ...]], +) -> str: + answers = {answer.question_id: answer for answer in bundle.answers} + observations_by_frame: dict[int, list[ObjectObservation]] = defaultdict(list) + for observation in bundle.observations: + observations_by_frame[observation.frame_id].append(observation) + + rows: list[str] = [] + for question in sorted(bundle.questions, key=lambda item: item.question_id): + try: + answer = answers[question.question_id] + except KeyError as error: + raise ValueError( + f"question is missing oracle answer: {question.question_id}" + ) from error + frame_ids = evidence_by_question.get(question.question_id, ()) + links = " ".join( + f'frame {frame_id}' + for frame_id in frame_ids + ) + rows.append( + "" + f"{escape(question.question_id)}" + f"{escape(question.text)}" + f"{str(answer.expected).lower()}" + f"{links or 'none'}" + "" + ) + + cards: list[str] = [] + evidence_frame_ids = sorted( + {frame_id for frame_ids in evidence_by_question.values() for frame_id in frame_ids} + ) + for frame_id in evidence_frame_ids: + labels = ", ".join( + f"{observation.object_id}: {observation.label}" + for observation in sorted( + observations_by_frame[frame_id], key=lambda item: item.object_id + ) + ) + cards.append( + "
" + f'' + f'Evidence frame {frame_id}' + f"
Frame {frame_id}: {escape(labels)}
" + "
" + ) + + return ( + """ + + + + +Spatiotemporal relationship evidence + + + +

Spatiotemporal relationship evidence

+

Review required: These are teacher pseudo-labels, not ground truth. Inspect boxes, identities, labels, and relationships before using the episode for model-quality claims.

+

Questions and private oracle

+ +""" + + "\n".join(rows) + + """ +
IDQuestionExpectedEvidence
+

Annotated evidence frames

+
""" + + "\n".join(cards) + + """
+ + +""" + ) + + +def write_evidence_viewer( + video_path: Path, + bundle: EvidenceBundle, + output_root: Path, +) -> EvidenceViewerResult: + """Write an evaluator-only HTML report linked to annotated private evidence.""" + _assert_no_symlink_components(video_path) + if not video_path.is_file(): + raise ValueError(f"evidence video must be a regular file: {video_path}") + _validate_output_root(output_root) + if video_path.resolve().is_relative_to(output_root.resolve(strict=False)): + raise ValueError("evidence video cannot be inside viewer output") + + evidence_by_question, frame_ids = _frame_evidence(bundle) + html = _render_html(bundle, evidence_by_question) + staging = _create_staging_root(output_root) + try: + _write_evidence_frames(video_path, staging, frame_ids, bundle.observations) + (staging / "index.html").write_text(html, encoding="utf-8") + _replace_output_atomically(staging, output_root) + finally: + if staging.exists(): + shutil.rmtree(staging) + return EvidenceViewerResult( + index_path="index.html", + evidence_frame_count=len(frame_ids), + question_count=len(bundle.questions), + ) diff --git a/dimos/benchmark/spatiotemporal/test_demo.py b/dimos/benchmark/spatiotemporal/test_demo.py index 5f756eac47..cd59be0342 100644 --- a/dimos/benchmark/spatiotemporal/test_demo.py +++ b/dimos/benchmark/spatiotemporal/test_demo.py @@ -102,6 +102,13 @@ def candidate( monkeypatch.setattr(demo, "_run_temporal_memory_candidate", candidate) monkeypatch.setattr(demo, "_score_candidate", lambda *_: {}) + monkeypatch.setattr( + demo, + "write_evidence_viewer", + lambda *_: SimpleNamespace( + index_path="index.html", evidence_frame_count=0, question_count=0 + ), + ) run_demo(source, output_root) @@ -109,6 +116,43 @@ def candidate( assert not observed["video"].exists() +def test_demo_writes_evaluator_evidence_viewer( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = tmp_path / "source.mp4" + source.write_bytes(b"video") + output_root = tmp_path / "output" + bundle = SimpleNamespace(questions=()) + observed: dict[str, object] = {} + + def trim(_source: Path, target: Path, _duration_s: float) -> tuple[float, int]: + target.write_bytes(b"trimmed video") + return 1.0, 25 + + def viewer(video: Path, private_bundle: object, root: Path) -> SimpleNamespace: + observed.update(video=video, bundle=private_bundle, root=root) + return SimpleNamespace(index_path="index.html", evidence_frame_count=3, question_count=7) + + monkeypatch.setattr(demo, "_trim_video", trim) + monkeypatch.setattr(demo, "_teacher_run", lambda *_: (bundle, {})) + monkeypatch.setattr(demo, "_run_temporal_memory_candidate", lambda *_: ({}, {})) + monkeypatch.setattr(demo, "_score_candidate", lambda *_: {}) + monkeypatch.setattr(demo, "write_evidence_viewer", viewer) + + summary = run_demo(source, output_root) + + assert observed == { + "video": output_root / "office_robot_25s.mp4", + "bundle": bundle, + "root": output_root / "evidence-viewer", + } + assert summary["review"] == { + "evidence_frame_count": 3, + "index_path": "evidence-viewer/index.html", + "question_count": 7, + } + + def test_invalid_stride_has_no_output_side_effect(tmp_path: Path) -> None: output_root = tmp_path / "output" with pytest.raises(ValueError, match="stride must be positive"): diff --git a/dimos/benchmark/spatiotemporal/test_evidence_viewer.py b/dimos/benchmark/spatiotemporal/test_evidence_viewer.py new file mode 100644 index 0000000000..99ab09ac40 --- /dev/null +++ b/dimos/benchmark/spatiotemporal/test_evidence_viewer.py @@ -0,0 +1,251 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the evaluator-only, human-inspectable evidence viewer.""" + +from pathlib import Path +from types import SimpleNamespace +from typing import cast + +import cv2 +import numpy as np +import pytest + +from dimos.benchmark.spatiotemporal.evidence_viewer import ( + EvidenceBundle, + _annotate_frame, + _object_color, + write_evidence_viewer, +) +from dimos.benchmark.spatiotemporal.models import ( + BoundingBox2D, + ObjectObservation, + OracleAnswer, + Question, + QuestionKind, + SpatialPredicate, +) +from dimos.benchmark.spatiotemporal.utilities import stable_question_id + + +class _FakeCapture: + def __init__(self, frame_count: int = 1) -> None: + self._frame_count = frame_count + self._frame_id = 0 + self.released = False + + def isOpened(self) -> bool: + return True + + def read(self) -> tuple[bool, np.ndarray | None]: + if self._frame_id >= self._frame_count: + return False, None + frame = np.full((20, 30, 3), self._frame_id, dtype=np.uint8) + self._frame_id += 1 + return True, frame + + def release(self) -> None: + self.released = True + + +def _bundle() -> EvidenceBundle: + question_id = stable_question_id( + episode_id="episode_1", + object_ids=("oven", "robot"), + predicate=SpatialPredicate.LEFT_OF.value, + question_kind=QuestionKind.SPATIAL.value, + ) + return cast( + "EvidenceBundle", + SimpleNamespace( + questions=( + Question( + question_id=question_id, + episode_id="episode_1", + text="Did ever appear left of robot?", + question_kind=QuestionKind.SPATIAL, + predicate=SpatialPredicate.LEFT_OF, + object_ids=("oven", "robot"), + ), + ), + answers=( + OracleAnswer( + question_id=question_id, + expected=True, + evidence_frame_ids=(0,), + ), + ), + observations=( + ObjectObservation( + episode_id="episode_1", + frame_id=0, + timestamp_s=0.0, + object_id="oven", + label="", + box=BoundingBox2D(x_min=0.1, y_min=0.1, x_max=0.4, y_max=0.6), + confidence=0.9, + ), + ObjectObservation( + episode_id="episode_1", + frame_id=1, + timestamp_s=0.1, + object_id="robot", + label="robot", + box=BoundingBox2D(x_min=0.5, y_min=0.1, x_max=0.8, y_max=0.6), + confidence=0.8, + ), + ), + relation_intervals=(), + ), + ) + + +def test_writes_deterministic_escaped_pseudo_label_evidence( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = tmp_path / "video.mp4" + source.write_bytes(b"decoded by fake capture") + captures: list[_FakeCapture] = [] + + def capture_factory(_: str) -> _FakeCapture: + capture = _FakeCapture() + captures.append(capture) + return capture + + monkeypatch.setattr(cv2, "VideoCapture", capture_factory) + roots = (tmp_path / "first", tmp_path / "second") + + results = tuple(write_evidence_viewer(source, _bundle(), root) for root in roots) + + assert results[0] == results[1] + assert results[0].evidence_frame_count == 1 + assert results[0].question_count == 1 + outputs = [ + {path.relative_to(root): path.read_bytes() for path in root.rglob("*") if path.is_file()} + for root in roots + ] + assert outputs[0] == outputs[1] + html = (roots[0] / "index.html").read_text(encoding="utf-8") + assert "teacher pseudo-labels, not ground truth" in html + assert "Did <oven> ever appear left of robot?" in html + assert "<oven>" in html + assert "" not in html + assert (roots[0] / "frames" / "frame_000000.jpg").is_file() + assert "frame_000001.jpg" not in html + assert not (roots[0] / "frames" / "frame_000001.jpg").exists() + assert all(capture.released for capture in captures) + + +def test_annotation_maps_normalized_box_to_frame_pixels() -> None: + observation = _bundle().observations[0] + annotated = _annotate_frame(np.zeros((20, 30, 3), dtype=np.uint8), (observation,)) + + assert tuple(int(component) for component in annotated[12, 12]) == _object_color( + observation.object_id + ) + + +def test_resolves_temporal_interval_to_sparse_nonzero_frame( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = tmp_path / "video.mp4" + source.write_bytes(b"video") + base = _bundle() + interval_id = f"interval_{'1' * 64}" + bundle = cast( + "EvidenceBundle", + SimpleNamespace( + questions=base.questions, + answers=( + OracleAnswer( + question_id=base.questions[0].question_id, + expected=True, + evidence_interval_ids=(interval_id,), + ), + ), + observations=base.observations, + relation_intervals=(SimpleNamespace(interval_id=interval_id, evidence_frame_ids=(2,)),), + ), + ) + monkeypatch.setattr(cv2, "VideoCapture", lambda _: _FakeCapture(frame_count=3)) + + result = write_evidence_viewer(source, bundle, tmp_path / "viewer") + + assert result.evidence_frame_count == 1 + assert (tmp_path / "viewer" / "frames" / "frame_000002.jpg").is_file() + html = (tmp_path / "viewer" / "index.html").read_text(encoding="utf-8") + assert "frame_000002.jpg" in html + + +def test_rejects_symlinked_viewer_output(tmp_path: Path) -> None: + source = tmp_path / "video.mp4" + source.write_bytes(b"video") + actual = tmp_path / "actual" + actual.mkdir() + linked = tmp_path / "linked" + linked.symlink_to(actual, target_is_directory=True) + + with pytest.raises(ValueError, match="symlink"): + write_evidence_viewer(source, _bundle(), linked) + + assert not tuple(actual.iterdir()) + + +def test_rejects_symlinked_output_ancestor(tmp_path: Path) -> None: + source = tmp_path / "video.mp4" + source.write_bytes(b"video") + external = tmp_path / "external" + external.mkdir() + linked_parent = tmp_path / "linked-parent" + linked_parent.symlink_to(external, target_is_directory=True) + + with pytest.raises(ValueError, match="symlink"): + write_evidence_viewer(source, _bundle(), linked_parent / "viewer") + + assert not tuple(external.iterdir()) + + +def test_rejects_source_inside_output_without_deleting_it(tmp_path: Path) -> None: + output = tmp_path / "viewer" + output.mkdir() + source = output / "video.mp4" + source.write_bytes(b"must survive") + + with pytest.raises(ValueError, match="inside viewer output"): + write_evidence_viewer(source, _bundle(), output) + + assert source.read_bytes() == b"must survive" + + +def test_failed_render_preserves_existing_viewer( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = tmp_path / "video.mp4" + source.write_bytes(b"video") + output = tmp_path / "viewer" + output.mkdir() + marker = output / "existing.txt" + marker.write_text("keep me", encoding="utf-8") + + class BrokenCapture(_FakeCapture): + def read(self) -> tuple[bool, None]: + return False, None + + monkeypatch.setattr(cv2, "VideoCapture", lambda _: BrokenCapture()) + + with pytest.raises(RuntimeError, match="decode evidence frame"): + write_evidence_viewer(source, _bundle(), output) + + assert marker.read_text(encoding="utf-8") == "keep me" + assert tuple(output.iterdir()) == (marker,) From 2af71d8d62942e13ac7ef3f0d9a05c12bca65920 Mon Sep 17 00:00:00 2001 From: fengweit <2412561+fengweit@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:26:31 +0800 Subject: [PATCH 06/17] docs(benchmark): clarify evaluator and baseline scope --- dimos/benchmark/spatiotemporal/README.md | 499 ++++++------------ dimos/benchmark/spatiotemporal/demo.py | 3 + dimos/benchmark/spatiotemporal/generation.py | 37 +- .../spatiotemporal/test_generation.py | 10 + 4 files changed, 204 insertions(+), 345 deletions(-) diff --git a/dimos/benchmark/spatiotemporal/README.md b/dimos/benchmark/spatiotemporal/README.md index 297c807d0b..8110378c07 100644 --- a/dimos/benchmark/spatiotemporal/README.md +++ b/dimos/benchmark/spatiotemporal/README.md @@ -1,197 +1,171 @@ -# Spatiotemporal Video-QA Benchmark +# Replayable Spatiotemporal Video-Relation Evals -This package provides a deterministic, privacy-separated benchmark for asking spatial and temporal questions about tracked objects in video. It includes strict data contracts, real OpenCV decoding, real YOLO-E detection/tracking, deterministic relation and question generation, public/private bundle separation, replay, exact scoring, a TemporalMemory candidate adapter, and a verified end-to-end demo. +This package turns a video into a deterministic evaluation bundle for spatial and temporal relationship questions. -The benchmark is designed so a candidate sees only the source video and answer-free public questions. Teacher observations, accepted relations, intervals, oracle answers, and evidence remain private until evaluation. +The core contribution is the evaluator, not a new vision model: -## What is real, and what is scripted +- a real video is decoded and sparsely sampled; +- YOLO-E produces teacher pseudo-labels and persistent object identities; +- geometry produces canonical spatial facts and sampled-frame intervals; +- deterministic generation produces answer-free public questions and private oracle evidence; +- the same saved observations replay to byte-stable logical bundles under different output roots; +- a candidate receives only the video and public questions; +- an evaluator joins predictions to private answers and emits exact diagnostics; and +- an evaluator-only HTML viewer makes every pseudo-label claim inspectable. -The end-to-end demo uses: +## What this demonstrates -- a real LFS-backed video; -- real OpenCV decoding and frame sampling; -- real YOLO-E 11s weights and inference; -- real persistent tracker IDs; -- real relation, interval, question, bundle, replay, and scoring code; and -- the real TemporalMemory accumulator, state, entity graph, query lifecycle, readiness, and cleanup paths. +The reference demo exercises real: -The demo's TemporalMemory VLM fixture is intentionally timestamp-scripted and its final yes/no baseline is a stable hash of the public question. It does **not** inspect image pixels and is **not** presented as a visual QA model. Its purpose is to verify TemporalMemory plumbing and the public/private boundary without requiring cloud credentials. The YOLO-E teacher path is the visually grounded part of the reference demo. +- Git-LFS video input; +- OpenCV decode, trim, encode, reopen, and full-frame verification; +- YOLO-E 11s prompt inference; +- tracker identity normalization; +- spatial relation and interval derivation; +- public/private bundle generation and validation; +- root-independent deterministic replay; +- candidate isolation; +- TemporalMemory ingestion/query/cleanup plumbing; and +- exact scoring and evidence rendering. -## Architecture +The demo does **not** claim that YOLO-E outputs are human ground truth. They are teacher pseudo-labels and must be reviewed in `evidence-viewer/index.html`. + +The included TemporalMemory candidate is also **not** a model-quality baseline. It uses a timestamp-scripted VLM fixture and a deterministic public-question hash for yes/no responses. Its score proves that candidate plumbing, isolation, readiness, prediction parsing, and scoring run end to end without cloud credentials. Replace it with a real candidate before drawing model-quality conclusions. + +## Evaluation boundary ```text -LFS video - | - v -OpenCVVideoSampler -----> YoloeObservationDetector -----> canonical ObjectObservation JSONL - | - v - spatial RelationFact records - | - v - strict RelationInterval records - | - +----------------------+----------------------+ - | | - v v - public Question records private OracleAnswer records - | | - v v - public bundle manifest <---- cryptographic bind ---- oracle manifest - | - v - CandidateAnswerer(video + public questions only) - | - v - Prediction records - | - +---------------- evaluator only ----------------+ - | - v - exact EvaluationReport +TEACHER / EVALUATOR CANDIDATE + +source video copied source video + | | +OpenCV sampling v + | TemporalMemory smoke fixture +YOLO-E pseudo-labels | + | v +canonical spatial facts public predictions + | +sampled-frame intervals + | + +--> public questions ----------------------+ + | + +--> private oracle answers/evidence + | + v + exact evaluator report ``` -Teacher and candidate execution are deliberately separate: +The candidate function receives only: -- `_run_temporal_memory_candidate(...)` accepts only the video, public `Question` records, an ephemeral candidate directory, and duration. -- `_score_candidate(...)` runs in the evaluator boundary and owns `OracleAnswer` records. -- The candidate function never receives an `EvaluationBundle`, observations, facts, intervals, evidence, or oracle answers. +1. a copied video in a temporary candidate directory; +2. public `Question` records; +3. that temporary directory; and +4. the public duration. -## Package map +It never receives an `EvaluationBundle`, observations, facts, intervals, evidence IDs, or expected answers. The candidate directory is outside teacher output and is deleted after execution. -| File | Purpose | -|---|---| -| `models.py` | Strict frozen Pydantic v2 records and closed predicate/status enums | -| `utilities.py` | Canonical JSON, schema version, and stable SHA-256 identifiers | -| `relations.py` | Image-plane `left-of`, `right-of`, `above`, and `below` derivation | -| `intervals.py` | Coalescing accepted relation facts into bounded intervals | -| `generation.py` | Spatial and strict temporal question generation | -| `bundles.py` | Canonical public/private artifacts, manifests, loading, and validation | -| `observation_io.py` | Strict canonical observation JSONL read/write | -| `replay.py` | Deterministic observation-to-bundle replay | -| `ports.py` | Frozen detector, generator, replay, and candidate seams | -| `video_adapter.py` | Deterministic OpenCV video sampling | -| `yoloe_adapter.py` | YOLO-E normalization and identity continuity statistics | -| `temporal_memory_answerer.py` | Public-only TemporalMemory candidate lifecycle adapter | -| `runner.py` | Candidate answer parsing and aggregate report construction | -| `scoring.py` | Exact Boolean scoring and evidence-linked diagnostics | -| `demo.py` | One-command real video/YOLO-E/TemporalMemory plumbing demo | -| `test_*.py` | Contract, privacy, determinism, safety, and integration tests | +## Semantics + +### Spatial facts -## Frozen contracts and invariants +Image-plane predicates are closed to: -### Record strictness +- `left-of` / `right-of`; +- `above` / `below`. -Every benchmark record derives from `StrictFrozenModel`: +Replay stores one canonical representation per physical relation: -- Pydantic v2 strict mode; -- `frozen=True`; -- `extra="forbid"`; -- no implicit type coercion; -- finite numeric values; -- normalized, non-empty strings; and -- deterministic validation of stable identifiers. +- horizontal facts use `left-of`; +- vertical facts use `above`. -Normalized bounding boxes use `[0, 1]` coordinates and must have strictly increasing minima/maxima. +This prevents inverse duplicates such as `A left-of B` and `B right-of A` from receiving double weight. -### Supported predicates +Spatial questions use episode-level existential wording, for example: -Spatial predicates are closed to: +```text +Did object_1 ever appear left of object_2? +``` + +For a stable physical relation, generation emits: -- `left-of` -- `right-of` -- `above` -- `below` +- one positive question for the observed canonical relation; and +- one negative control for the opposite predicate only when that opposite relation never occurred in the episode. -Temporal predicates are closed to: +If a relation flips during the episode, both observed directions become positives and contradictory negatives are omitted. Negative evidence covers the complete sampled-frame schedule. -- `before` -- `after` +### Temporal facts -Temporal comparisons are strict. Touching or overlapping intervals are neither strictly before nor strictly after. +Facts coalesce when they occur at adjacent **sample positions**, not when raw source frame IDs differ by one. For a stride of 150, frames 0 and 150 are adjacent samples. An intervening sampled frame with no relation breaks the interval, including a zero-detection frame. -### Stable identifiers +Temporal questions are generated only from strict, unanimous ordering proofs. Touching, overlapping, containing, or contradictory intervals produce no temporal claim. -Question, relation, interval, and bundle IDs are SHA-256-derived stable IDs. Their preimages include the relevant schema version and semantic fields. Question IDs include the episode identity so equivalent questions from different episodes cannot collide. +Public temporal text is human-readable: + +```text +Did "object_1 left of object_2" happen before "object_3 above object_4"? +``` -Do not change stable-ID preimages, enum values, or schema version casually. Such a change is a benchmark schema migration and requires explicit compatibility analysis and regenerated fixtures. +Stable relation IDs remain in structured `reference_ids`; private interval IDs remain in oracle evidence. -### Relation and interval rules +### Stable contracts -- Relation subjects and objects must differ. -- Fact evidence cites exactly the fact's sampled frame. -- Evidence IDs are ordered and unique. -- An interval represents one stable relation ID. -- Frame and timestamp endpoints must agree. -- A single-frame interval must also have identical timestamps; a multi-frame interval must span increasing timestamps. -- Facts and intervals are private teacher artifacts. +All records are strict frozen Pydantic v2 models with `extra="forbid"` and no implicit coercion. -### Question and oracle rules +Current schema: -- Public questions contain no expected answer or teacher evidence. -- Spatial public questions refer to object IDs and one spatial predicate. -- Temporal public questions refer to stable relation IDs, not private interval payloads. -- Oracle answers carry the expected Boolean and private evidence frame/interval IDs. -- Candidate predictions accept only exact booleans or the exact lowercase strings `yes` and `no`. -- Missing, invalid, correct, and incorrect are distinct scoring statuses. +```text +spatiotemporal-video-qa/v2 +``` -### Bundle privacy +Question IDs include episode identity. Relation, interval, question, and bundle IDs are SHA-256 values over canonical semantic preimages. Changing a preimage, enum value, or schema version is an explicit migration and requires regenerated artifacts. -The public bundle contains only release metadata and answer-free public questions. The private oracle bundle contains observations, relation facts, intervals, answers, and evidence. Both manifests share one `bundle_id`; the oracle manifest is cryptographically bound to the exact public manifest SHA-256. +## Package map -Logical replay hashing is root-independent: writing the same logical bundle under different directories must produce the same manifests and logical digest. +| File | Responsibility | +|---|---| +| `models.py` | Strict records and closed enums | +| `utilities.py` | Canonical JSON and stable IDs | +| `relations.py` | Image-plane geometry | +| `intervals.py` | Sample-schedule-aware interval construction | +| `generation.py` | Balanced spatial and strict temporal cases | +| `observation_io.py` | Canonical observation JSONL | +| `bundles.py` | Public/private artifacts and manifests | +| `replay.py` | Observation-to-bundle replay | +| `video_adapter.py` | OpenCV sampling, including zero-detection samples | +| `yoloe_adapter.py` | YOLO-E normalization and identity statistics | +| `evidence_viewer.py` | Private HTML and annotated evidence frames | +| `temporal_memory_answerer.py` | Public-only candidate lifecycle adapter | +| `runner.py` / `scoring.py` | Parsing, scoring, and diagnostics | +| `demo.py` | Real end-to-end acceptance command | ## Prerequisites -Run from the repository root with the project environment available through `uv`. +Run from the repository root. -### 1. Git LFS - -Install Git LFS, initialize it for this repository, and materialize the video and YOLO-E archive: +### Materialize LFS assets ```bash -git lfs version git lfs install --local git lfs pull --include='assets/simple_demo.mp4,data/.lfs/models_yoloe.tar.gz' -``` - -Verify that the files are binaries rather than ~130-byte pointer files: - -```bash python3 - <<'PY' from pathlib import Path -for name in ("assets/simple_demo.mp4", "data/.lfs/models_yoloe.tar.gz"): - path = Path(name) - print(name, path.stat().st_size, "bytes") - assert path.stat().st_size > 1024, f"{name} is still an LFS pointer" +for name in ('assets/simple_demo.mp4', 'data/.lfs/models_yoloe.tar.gz'): + size = Path(name).stat().st_size + print(name, size) + assert size > 1024, f'{name} is still an LFS pointer' PY ``` -Reference sizes are approximately 51 MB for `simple_demo.mp4` and 185 MB for `models_yoloe.tar.gz`. - -### 2. Extract YOLO-E weights +### Extract weights and install the YOLO-E prompt dependency ```bash uv run python -c "from dimos.utils.data import get_data; print(get_data('models_yoloe'))" -``` - -The reference prompt model is: - -```text -data/models_yoloe/yoloe-11s-seg.pt -``` - -### 3. Install Ultralytics CLIP - -YOLO-E text prompting requires Ultralytics' CLIP fork: - -```bash uv pip install 'git+https://github.com/ultralytics/CLIP.git' ``` -The first prompt-mode run may also download `mobileclip_blt.ts` into the Ultralytics user cache. +The first prompt-mode run may download `mobileclip_blt.ts` into the Ultralytics cache. -## One-command demo +## Run the real demo ```bash uv run python -m dimos.benchmark.spatiotemporal.demo \ @@ -201,74 +175,52 @@ uv run python -m dimos.benchmark.spatiotemporal.demo \ --frame-stride 150 ``` -Constraints: +The command uses CPU for reproducibility. Duration must be 15–30 seconds and stride must be positive. -- duration must be between 15 and 30 seconds; -- frame stride must be positive; -- the source must be a regular non-symlink file; -- the output path and fixed artifact children must not contain symlinks; -- source and target must not alias by path or hard link; and -- encoded output is reopened and every expected frame is decoded before inference begins. - -The reference command uses CPU explicitly for reproducibility. - -## Output layout +## Output ```text .artifacts/spatiotemporal-video-qa/ ├── office_robot_25s.mp4 ├── observations.jsonl ├── summary.json +├── evidence-viewer/ +│ ├── index.html +│ └── frames/frame_*.jpg ├── bundle-a/ │ ├── public/ │ └── oracle/ -├── bundle-b/ -│ ├── public/ -│ └── oracle/ +└── bundle-b/ + ├── public/ + └── oracle/ ``` -`bundle-a` and `bundle-b` are independent writes of the same logical teacher data. Their public and oracle manifests must compare equal. -TemporalMemory state is written under an ephemeral directory outside this teacher output and removed after candidate execution. - -## What the demo verifies +`bundle-a` and `bundle-b` are independent writes of the same logical teacher data. Their manifests and logical digest must match. -The command fails unless all of these gates hold: +The viewer is evaluator-only because it contains expected answers and private evidence. It is generated atomically, escapes untrusted text, rejects symlinked path components, preserves an existing viewer on failure, and never writes into the candidate directory. -1. the constrained output video contains exactly the expected number of decodable frames; -2. two fresh YOLO-E detector/tracker instances produce identical observations and statistics; -3. canonical observations can be written and strictly re-read; -4. observations produce accepted spatial facts and intervals; -5. the teacher generates at least one public question; -6. two independent bundle roots have equal public and oracle manifests; -7. replay logical SHA-256 values match across roots; -8. TemporalMemory ingests public frames and reports ready; -9. all public questions receive a valid candidate response; -10. scoring joins every public question to exactly one private oracle answer; and -11. TemporalMemory and detector resources are closed. +## Five-minute reviewer walkthrough -## Reference result +1. Open `evidence-viewer/index.html`. +2. Confirm boxes and IDs follow the same physical objects across sampled frames. +3. Treat labels as pseudo-labels; note any semantically weak detection. +4. Check positive and negative spatial questions against the linked frames. +5. Check temporal wording and interval evidence. +6. Inspect `bundle-a/public/questions.jsonl`: it must contain no expected answers or evidence. +7. Inspect `bundle-a/oracle/answers.jsonl`: it must remain private. +8. Compare `bundle-a` and `bundle-b` manifests and the logical digest in `summary.json`. +9. Confirm `summary.json` labels the candidate as a non-visual plumbing smoke test. -Schema v2 makes question IDs episode-scoped, so v1 artifact and summary hashes are intentionally incompatible. Run the one-command demo to generate a fresh local reference; the final acceptance section records counts and digests only after all schema-v2 feature work is complete. - -## Verification commands - -### Contract and integration tests +## Quality gates ```bash uv run pytest dimos/benchmark/spatiotemporal -q -``` - -Expected result for this curated import: 105 tests pass. This count grows with the focused TDD changes below. - -### Formatting, lint, and typing - -```bash uv run ruff format --check dimos/benchmark/spatiotemporal uv run ruff check dimos/benchmark/spatiotemporal -uv run mypy +uv run --group lint mypy dimos/benchmark/spatiotemporal ``` -### Inspect the machine-readable result +Inspect the real-run summary: ```bash python3 - <<'PY' @@ -278,22 +230,24 @@ s = json.loads(Path('.artifacts/spatiotemporal-video-qa/summary.json').read_text assert s['video']['duration_s'] == 25 assert s['video']['frames'] == 750 assert s['teacher']['detector_repeat_equal'] is True -assert len(s['teacher']['unique_object_ids']) >= 3 assert s['teacher']['relation_facts'] > 0 assert s['teacher']['relation_intervals'] > 0 assert s['teacher']['spatial_questions'] > 0 assert s['teacher']['temporal_questions'] > 0 assert s['candidate']['candidate_used_oracle'] is False +assert s['candidate']['baseline_kind'] == 'public_only_plumbing_smoke_test' +assert s['candidate']['visually_grounded'] is False assert s['candidate']['readiness']['ready'] is True -assert s['candidate']['readiness']['ingested_frame_count'] == 25 assert s['candidate']['questions_answered'] == s['teacher']['questions'] assert s['candidate']['status_counts']['missing'] == 0 assert s['candidate']['status_counts']['invalid'] == 0 +assert s['review']['question_count'] == s['teacher']['questions'] +assert Path('.artifacts/spatiotemporal-video-qa', s['review']['index_path']).is_file() print('SUMMARY_GATES=PASS') PY ``` -### Verify complete-run repeatability +Verify complete-run determinism: ```bash cp .artifacts/spatiotemporal-video-qa/summary.json /tmp/stqa-summary-first.json @@ -306,150 +260,45 @@ cmp /tmp/stqa-summary-first.json .artifacts/spatiotemporal-video-qa/summary.json shasum -a 256 .artifacts/spatiotemporal-video-qa/summary.json ``` -`cmp` must produce no output and exit zero. - -## Safety and privacy checks - -The test suite covers: - -- candidate function signature excludes private bundles; -- candidate answers are stable when irrelevant context timestamps change; -- invalid duration and stride fail before output side effects; -- symlinked output roots and artifact children are rejected; -- source/target path aliases are rejected; -- source/target hard-link aliases are rejected without modifying the source; -- silent `VideoWriter.write()` failure is detected by full decode verification; -- duplicate fallback detector labels are rejected; -- observations reject duplicate JSON keys and noncanonical JSONL; -- public manifests cannot reference private artifacts; -- oracle manifests bind to one exact public manifest; and -- questions, answers, and evidence IDs must join exactly. - -Do not weaken these checks to make a custom video pass. Improve the input, prompts, detector settings, or explicitly version the benchmark contract. - -## Using a different video - -A useful custom video should provide: - -- a valid finite FPS; -- 15-30 seconds of decodable frames; -- at least three reliably tracked native identities; -- spatially separated object pairs; and -- at least one change that creates non-overlapping relation intervals for temporal questions. - -The default prompt set is defined in `demo.py`. Multiple untracked detections with the same fallback label are intentionally rejected because label-only IDs would be ambiguous. Prefer native tracker IDs or a prompt set that yields unique fallback labels. - -The demo episode ID is currently fixed for the reference clip. If generalizing the CLI for arbitrary datasets, add an explicit episode argument and preserve the stable-ID contract. - -## Development and integration guidance - -This benchmark was developed as isolated lanes and should continue to use the same discipline for contract-sensitive changes: - -- one branch and worktree per independent lane; -- the main worktree is reserved for controlled integration; -- shared contracts are frozen before parallel implementation; -- lane ownership guards prevent edits outside assigned paths; -- progress is tracked in machine-readable lane ledgers; -- workers commit and push small independently testable units; -- only integration updates the global board, resolves conflicts, or runs private/real gates; -- do not merge a lane until its focused tests, full package tests, Ruff, mypy, and remote-head verification pass; -- keep candidate-facing code physically and structurally separated from oracle data; and -- never use oracle answers to tune or generate candidate predictions during a scored run. - -The completed lane and gate record is in: - -```text -.hermes/plans/spatiotemporal-video-qa-parallel/board.md -``` - -## Independent verification agent runbook - -A verifier must operate read-only and treat this README as the procedure under test. - -### Agent rules - -1. Start from `/Users/tian/dimos` on `feat/spatiotemporal-video-qa`. -2. Do not edit, format, commit, reset, clean, or push. -3. Do not read `.env`, credentials, or private user files. -4. Confirm the Git worktree is clean before verification. -5. Follow the prerequisites and one-command demo in this README. -6. Run the package tests, Ruff checks, and mypy. -7. Run the summary assertions and repeatability check. -8. Inspect code/tests for each advertised privacy and safety invariant. -9. Distinguish real YOLO-E inference from the timestamp-scripted TemporalMemory VLM fixture. -10. Return `PASS` only if every applicable gate passes; otherwise return `FAIL` with exact command, exit code, and evidence. - -### Required report - -```text -VERDICT: PASS|FAIL -BRANCH: -HEAD: -WORKTREE_CLEAN: yes|no -LFS_ASSETS: PASS|FAIL -YOLOE_WEIGHTS: PASS|FAIL -PYTEST: -RUFF_FORMAT: PASS|FAIL -RUFF_CHECK: PASS|FAIL -MYPY: PASS|FAIL -REAL_DEMO: PASS|FAIL -DETECTOR_REPEAT: PASS|FAIL -BUNDLE_REPEAT: PASS|FAIL -FULL_RUN_REPEAT: PASS|FAIL -TEMPORAL_MEMORY_READY: PASS|FAIL -PRIVACY_BOUNDARY: PASS|FAIL -PATH_SAFETY: PASS|FAIL -SUMMARY_SHA256: -NOTES: -``` - -## Troubleshooting - -### `moov atom not found` - -The video is probably still an LFS pointer. Check its size and run the Git LFS pull command again. - -### `ModuleNotFoundError: No module named 'clip'` - -Install the Ultralytics CLIP fork with the command in Prerequisites. - -### MobileCLIP download starts on first run - -This is expected for YOLO-E text prompting. The file is large and cached after download. - -### `duplicate fallback label` - -The tracker returned multiple detections of one prompt class without native IDs. The adapter rejects this instead of inventing ambiguous identity. Adjust prompts/confidence or use a video with stronger track continuity. - -### `no spatially separated object pairs` - -The saved observations did not produce accepted left/right/above/below relations. Inspect detections, normalized boxes, prompts, sampling stride, and relation margin. - -### `accepted relations produced no evaluation questions` - -The relation set is valid but insufficient for current question generation. Inspect relation IDs and interval ordering rather than bypassing the error. +`cmp` must be silent and exit zero. -### OpenCV AVFoundation duplicate-class warning on macOS +## Safety properties covered by tests -The `cv2` and `av` wheels may both bundle FFmpeg AVFoundation classes. The reference run emits this warning but completes successfully. Treat a crash or decode failure as an error; do not treat the warning alone as failure. +- strict and frozen records; +- duplicate-key and noncanonical JSON rejection; +- episode-safe question IDs; +- sample-schedule validation and zero-detection frames; +- inverse-relation canonicalization; +- relation-flip order independence; +- public/private manifest binding; +- exactly one oracle answer per public question; +- root-independent replay; +- candidate signature and filesystem isolation; +- source/target path and hard-link protection; +- symlinked output and ancestor rejection; +- full encoded-video decode verification; +- deterministic, escaped evidence HTML; +- temporal interval-to-frame evidence resolution; and +- failure-clean atomic viewer replacement. -### Slow CPU inference +## Extending the evaluator -The reference detector explicitly uses CPU for reproducibility. Increase `--frame-stride` to reduce inference calls for exploratory runs, but do not compare resulting hashes or counts to the reference configuration. +A production candidate should implement the public `CandidateAnswerer` lifecycle: -### Output path rejected +1. ingest the public video; +2. report readiness; +3. answer each public `Question`; and +4. release resources. -The demo refuses symlinked roots/children, non-directory roots, source/target aliases, hard-link aliases, and unexpected directory artifacts. Use a fresh regular directory under the repository, such as `.artifacts/spatiotemporal-video-qa`. +Do not pass private bundle records into candidate code. Do not use oracle answers to tune predictions during a scored run. Keep teacher generation replayable from saved observations so evaluator changes can be compared independently from detector changes. -## Current verification record +## Known limitations -- Integration branch: `feat/spatiotemporal-video-qa` -- Demo implementation commit: `1b9f7dae68e2023867dc20e90b4e7dfd122a6e14` -- Completion record commit: `c34658992e9bf40780e26cb92de7a02876430a94` -- Package tests: 108 passed -- Ruff: passed -- mypy: passed across 874 source files -- Independent code review: passed -- Real demo: passed twice with byte-identical summary +- YOLO-E labels and tracks are pseudo-labels and can be semantically wrong. +- Relations are 2D image-plane geometry, not metric 3D scene relations. +- Sparse sampling can miss short-lived events. +- Episode-level negative questions require the complete sampled-frame schedule. +- The reference candidate score is not a visual-model benchmark. +- The reference episode ID is fixed in the demo; a dataset CLI should expose it explicitly. -For a shorter operator-oriented version, see `docs/spatiotemporal-video-qa-demo.md`. +If a custom video fails, improve the video, prompts, tracking, confidence threshold, sampling schedule, or versioned benchmark contract. Do not weaken privacy, determinism, or path-safety checks to force a pass. diff --git a/dimos/benchmark/spatiotemporal/demo.py b/dimos/benchmark/spatiotemporal/demo.py index 2cd6d2277e..679e440795 100644 --- a/dimos/benchmark/spatiotemporal/demo.py +++ b/dimos/benchmark/spatiotemporal/demo.py @@ -366,10 +366,13 @@ def ingest(path: Path) -> int: question.question_id: answerer.answer(question) for question in questions } runtime = { + "baseline_kind": "public_only_plumbing_smoke_test", "no_answers": sum(answer == "no" for answer in candidate_answers.values()), "questions_answered": len(candidate_answers), "readiness": readiness.model_dump(mode="json"), + "score_interpretation": "not_model_quality", "temporal_memory_state": state, + "visually_grounded": False, "vlm_mode": "timestamp_scripted_temporal_memory_plumbing", "yes_answers": sum(answer == "yes" for answer in candidate_answers.values()), } diff --git a/dimos/benchmark/spatiotemporal/generation.py b/dimos/benchmark/spatiotemporal/generation.py index 2ffbcf81ed..e5a993df18 100644 --- a/dimos/benchmark/spatiotemporal/generation.py +++ b/dimos/benchmark/spatiotemporal/generation.py @@ -15,6 +15,7 @@ """Deterministic public question generation from accepted relation facts.""" from collections.abc import Sequence +from itertools import combinations, product from dimos.benchmark.spatiotemporal.models import ( OracleAnswer, @@ -130,14 +131,15 @@ def generate_temporal_question_cases( if len({interval.episode_id for interval in intervals}) > 1: raise ValueError("temporal questions must be generated from one episode") - proofs: dict[ - tuple[str, str], - dict[tuple[str, str], list[tuple[RelationInterval, RelationInterval]]], - ] = {} - for index, left in enumerate(intervals): - for right in intervals[index + 1 :]: - if left.relation_id == right.relation_id: - continue + by_relation: dict[str, list[RelationInterval]] = {} + for interval in intervals: + by_relation.setdefault(interval.relation_id, []).append(interval) + + proofs: dict[tuple[str, str], list[tuple[RelationInterval, RelationInterval]]] = {} + for left_id, right_id in combinations(sorted(by_relation), 2): + interval_proofs: list[tuple[RelationInterval, RelationInterval]] = [] + orientations: set[tuple[str, str]] = set() + for left, right in product(by_relation[left_id], by_relation[right_id]): if ( left.end_frame_id < right.start_frame_id and left.end_timestamp_s < right.start_timestamp_s @@ -149,21 +151,16 @@ def generate_temporal_question_cases( ): first, second = right, left else: - continue - - relation_pair = ( - min(first.relation_id, second.relation_id), - max(first.relation_id, second.relation_id), - ) - orientation = (first.relation_id, second.relation_id) - proofs.setdefault(relation_pair, {}).setdefault(orientation, []).append((first, second)) + interval_proofs = [] + break + interval_proofs.append((first, second)) + orientations.add((first.relation_id, second.relation_id)) + if interval_proofs and len(orientations) == 1: + proofs[(left_id, right_id)] = interval_proofs cases: dict[str, tuple[Question, OracleAnswer]] = {} for relation_pair in sorted(proofs): - orientations = proofs[relation_pair] - if len(orientations) != 1: - continue - interval_proofs = next(iter(orientations.values())) + interval_proofs = proofs[relation_pair] first, second = min( interval_proofs, key=lambda proof: (proof[0].interval_id, proof[1].interval_id), diff --git a/dimos/benchmark/spatiotemporal/test_generation.py b/dimos/benchmark/spatiotemporal/test_generation.py index cfd7f2c8fc..eda84f4f05 100644 --- a/dimos/benchmark/spatiotemporal/test_generation.py +++ b/dimos/benchmark/spatiotemporal/test_generation.py @@ -316,6 +316,16 @@ def question_ids(episode_id: str) -> set[str]: assert question_ids("episode_1").isdisjoint(question_ids("episode_2")) +def test_omits_temporal_questions_when_any_interval_pair_is_non_strict() -> None: + relation_a_early = _interval("obj_red", SpatialPredicate.LEFT_OF, "obj_blue", 1, 2) + relation_a_overlap = _interval("obj_red", SpatialPredicate.LEFT_OF, "obj_blue", 4, 7) + relation_b = _interval("obj_green", SpatialPredicate.ABOVE, "obj_yellow", 5, 6) + + assert ( + generate_temporal_question_cases((relation_a_early, relation_a_overlap, relation_b)) == () + ) + + def test_omits_temporal_questions_with_bidirectional_interval_proofs() -> None: relation_a_early = _interval("obj_red", SpatialPredicate.LEFT_OF, "obj_blue", 1, 2) relation_b_early = _interval("obj_green", SpatialPredicate.ABOVE, "obj_yellow", 4, 5) From 8cb4e703599e4e22f575d029bfe6c344bd5aec67 Mon Sep 17 00:00:00 2001 From: fengweit <2412561+fengweit@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:34:55 +0800 Subject: [PATCH 07/17] fix(benchmark): resolve macOS candidate temp path --- dimos/benchmark/spatiotemporal/README.md | 17 +++++++++++++++++ dimos/benchmark/spatiotemporal/demo.py | 2 +- dimos/benchmark/spatiotemporal/test_demo.py | 1 + 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/dimos/benchmark/spatiotemporal/README.md b/dimos/benchmark/spatiotemporal/README.md index 8110378c07..1d8b3270a3 100644 --- a/dimos/benchmark/spatiotemporal/README.md +++ b/dimos/benchmark/spatiotemporal/README.md @@ -199,6 +199,23 @@ The command uses CPU for reproducibility. Duration must be 15–30 seconds and s The viewer is evaluator-only because it contains expected answers and private evidence. It is generated atomically, escapes untrusted text, rejects symlinked path components, preserves an existing viewer on failure, and never writes into the candidate directory. +## Verified reference run + +The schema-v2 acceptance run on macOS CPU completed twice with byte-identical `summary.json` files: + +- 750 decoded frames over 25 seconds; +- 5 sampled frames per independent detector run; +- 8 YOLO-E observations with 4 native tracker IDs and no fallback IDs; +- 3 canonical relation facts and 3 sampled-frame intervals; +- 18 questions and answers: 6 spatial and 12 temporal; +- 5 annotated evidence frames; +- detector observations equal across two fresh YOLO-E instances; +- bundle logical SHA-256 `07b7b99844b01badbbdecd65227144cdee7d7ef1e6d6671f2f022bc7498eaaf0`; +- full-summary SHA-256 `9a679fe7b1689d5d988f567c87c8be636b7e35d55079c377f58dda79a0e1948c`; and +- 18/18 valid smoke-baseline predictions with zero missing or invalid responses. + +Visual inspection confirmed that the boxes are geometrically aligned where detections exist. It also exposed the intended pseudo-label caveats: the same physical robot changes tracker identity from `2` to `3`, the teacher calls a built-in oven/cabinet column a `refrigerator`, and the visible robot is not detected in every sampled frame. These labels are suitable for demonstrating evaluator mechanics, not for claiming a clean human-annotated benchmark. + ## Five-minute reviewer walkthrough 1. Open `evidence-viewer/index.html`. diff --git a/dimos/benchmark/spatiotemporal/demo.py b/dimos/benchmark/spatiotemporal/demo.py index 679e440795..1e9045419b 100644 --- a/dimos/benchmark/spatiotemporal/demo.py +++ b/dimos/benchmark/spatiotemporal/demo.py @@ -425,7 +425,7 @@ def run_demo( fps, frames = _trim_video(source_video, video, float(duration_s)) bundle, teacher = _teacher_run(video, output_root, frame_stride) with TemporaryDirectory(prefix="dimos-stqa-candidate-") as candidate_directory: - candidate_root = Path(candidate_directory) + candidate_root = Path(candidate_directory).resolve() candidate_video = candidate_root / video.name shutil.copyfile(video, candidate_video) predictions, runtime = _run_temporal_memory_candidate( diff --git a/dimos/benchmark/spatiotemporal/test_demo.py b/dimos/benchmark/spatiotemporal/test_demo.py index cd59be0342..65286e4c2b 100644 --- a/dimos/benchmark/spatiotemporal/test_demo.py +++ b/dimos/benchmark/spatiotemporal/test_demo.py @@ -93,6 +93,7 @@ def candidate( del questions, duration_s observed["root"] = candidate_root observed["video"] = video + assert candidate_root == candidate_root.resolve() assert not candidate_root.is_relative_to(output_root.resolve()) assert video.parent == candidate_root assert not video.is_relative_to(output_root.resolve()) From 1ce4dd1186d46e9c7cfef5ee851bc04b3b97824f Mon Sep 17 00:00:00 2001 From: fengweit <2412561+fengweit@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:06:11 +0800 Subject: [PATCH 08/17] fix(benchmark): isolate candidate logs in subprocess --- .../spatiotemporal/candidate_worker.py | 74 ++++++++++++++++++ dimos/benchmark/spatiotemporal/demo.py | 77 ++++++++++++++++--- dimos/benchmark/spatiotemporal/test_demo.py | 43 +++++++++++ 3 files changed, 183 insertions(+), 11 deletions(-) create mode 100644 dimos/benchmark/spatiotemporal/candidate_worker.py diff --git a/dimos/benchmark/spatiotemporal/candidate_worker.py b/dimos/benchmark/spatiotemporal/candidate_worker.py new file mode 100644 index 0000000000..eccb7b8307 --- /dev/null +++ b/dimos/benchmark/spatiotemporal/candidate_worker.py @@ -0,0 +1,74 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Subprocess entry point for the filesystem-isolated public candidate.""" + +import argparse +import json +from pathlib import Path + + +def _candidate_child(root: Path, path: Path, *, must_exist: bool) -> Path: + resolved_root = root.resolve(strict=True) + resolved = path.resolve(strict=must_exist) + if not resolved.is_relative_to(resolved_root): + raise ValueError(f"candidate path escapes temporary root: {path}") + if path.is_symlink(): + raise ValueError(f"candidate path must not be a symlink: {path}") + return resolved + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--video", type=Path, required=True) + parser.add_argument("--questions", type=Path, required=True) + parser.add_argument("--output-root", type=Path, required=True) + parser.add_argument("--duration-s", type=int, required=True) + parser.add_argument("--result", type=Path, required=True) + args = parser.parse_args() + + output_root = args.output_root.resolve(strict=True) + video = _candidate_child(output_root, args.video, must_exist=True) + questions_path = _candidate_child(output_root, args.questions, must_exist=True) + result_path = _candidate_child(output_root, args.result, must_exist=False) + + from dimos.benchmark.spatiotemporal.demo import ( + _run_temporal_memory_candidate_in_process, + ) + from dimos.benchmark.spatiotemporal.models import Question + + questions = tuple( + Question.model_validate_json(line) + for line in questions_path.read_text(encoding="utf-8").splitlines() + if line + ) + answers, runtime = _run_temporal_memory_candidate_in_process( + video, + questions, + output_root, + args.duration_s, + ) + result_path.write_text( + json.dumps( + {"answers": answers, "runtime": runtime}, + sort_keys=True, + separators=(",", ":"), + ) + + "\n", + encoding="utf-8", + ) + + +if __name__ == "__main__": + main() diff --git a/dimos/benchmark/spatiotemporal/demo.py b/dimos/benchmark/spatiotemporal/demo.py index 1e9045419b..4ecdf2dd4b 100644 --- a/dimos/benchmark/spatiotemporal/demo.py +++ b/dimos/benchmark/spatiotemporal/demo.py @@ -26,8 +26,10 @@ import os from pathlib import Path import shutil +import subprocess +import sys from tempfile import TemporaryDirectory -from typing import Any +from typing import Any, cast import cv2 @@ -38,6 +40,7 @@ from dimos.benchmark.spatiotemporal.replay import replay_observations from dimos.benchmark.spatiotemporal.runner import build_evaluation_report from dimos.benchmark.spatiotemporal.temporal_memory_answerer import TemporalMemoryAnswerer +from dimos.benchmark.spatiotemporal.utilities import canonical_model_json from dimos.benchmark.spatiotemporal.video_adapter import OpenCVVideoSampler from dimos.benchmark.spatiotemporal.yoloe_adapter import ( YoloeAdapterStatistics, @@ -301,16 +304,9 @@ def _teacher_run( return first_bundle, summary -def _run_temporal_memory_candidate( - video: Path, - questions: Sequence[Question], - output_root: Path, - duration_s: int, -) -> tuple[dict[QuestionId, str | bool | None], dict[str, Any]]: - """Run a public-only candidate and return predictions without oracle access.""" - database_root = _safe_child(output_root, "temporal-memory") - _reset_directory(database_root) - temporal_memory = TemporalMemory( +def _build_temporal_memory(database_root: Path, duration_s: int) -> TemporalMemory: + """Construct TemporalMemory with persistent paths selected by the worker environment.""" + return TemporalMemory( vlm=TimestampScriptedPublicVlModel(), db_dir=str(database_root), new_memory=True, @@ -325,6 +321,18 @@ def _run_temporal_memory_candidate( use_clip_filtering=False, ) + +def _run_temporal_memory_candidate_in_process( + video: Path, + questions: Sequence[Question], + output_root: Path, + duration_s: int, +) -> tuple[dict[QuestionId, str | bool | None], dict[str, Any]]: + """Run a public-only candidate and return predictions without oracle access.""" + database_root = _safe_child(output_root, "temporal-memory") + _reset_directory(database_root) + temporal_memory = _build_temporal_memory(database_root, duration_s) + def ingest(path: Path) -> int: temporal_memory._accumulator.set_start_time(0.0) capture = cv2.VideoCapture(str(path)) @@ -381,6 +389,53 @@ def ingest(path: Path) -> int: answerer.close() +def _run_temporal_memory_candidate( + video: Path, + questions: Sequence[Question], + output_root: Path, + duration_s: int, +) -> tuple[dict[QuestionId, str | bool | None], dict[str, Any]]: + """Run the public-only candidate in a subprocess with isolated logs and storage.""" + questions_path = _safe_child(output_root, "candidate-questions.jsonl") + result_path = _safe_child(output_root, "candidate-result.json") + questions_path.write_text( + "".join(f"{canonical_model_json(question)}\n" for question in questions), + encoding="utf-8", + ) + child_env = os.environ.copy() + child_env["DIMOS_RUN_LOG_DIR"] = str(_safe_child(output_root, "run-logs")) + command = [ + sys.executable, + "-m", + "dimos.benchmark.spatiotemporal.candidate_worker", + "--video", + str(video), + "--questions", + str(questions_path), + "--output-root", + str(output_root), + "--duration-s", + str(duration_s), + "--result", + str(result_path), + ] + subprocess.run(command, check=True, cwd=Path.cwd(), env=child_env) + payload = json.loads(result_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError("candidate worker result must be an object") + raw_answers = payload.get("answers") + runtime = payload.get("runtime") + if not isinstance(raw_answers, dict) or not isinstance(runtime, dict): + raise ValueError("candidate worker result has invalid answers or runtime") + expected_ids = {question.question_id for question in questions} + if set(raw_answers) != expected_ids: + raise ValueError("candidate worker answers do not match public questions") + for answer in raw_answers.values(): + if answer is not None and not isinstance(answer, str | bool): + raise ValueError("candidate worker returned an invalid answer type") + return cast("dict[QuestionId, str | bool | None]", raw_answers), cast("dict[str, Any]", runtime) + + def _score_candidate( bundle: EvaluationBundle, candidate_answers: dict[QuestionId, str | bool | None], diff --git a/dimos/benchmark/spatiotemporal/test_demo.py b/dimos/benchmark/spatiotemporal/test_demo.py index 65286e4c2b..768b909af7 100644 --- a/dimos/benchmark/spatiotemporal/test_demo.py +++ b/dimos/benchmark/spatiotemporal/test_demo.py @@ -15,6 +15,8 @@ """Tests for the one-command spatiotemporal video-QA demonstration.""" import inspect +import json +import os from pathlib import Path from types import SimpleNamespace @@ -68,6 +70,47 @@ def test_candidate_boundary_accepts_no_private_bundle() -> None: ) +def test_temporal_memory_subprocess_overrides_ambient_log_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + outside = tmp_path / "ambient-logs" + candidate_root = tmp_path / "candidate" + candidate_root.mkdir() + video = candidate_root / "video.mp4" + video.write_bytes(b"video") + observed: dict[str, object] = {} + monkeypatch.setenv("DIMOS_RUN_LOG_DIR", str(outside)) + + def fake_run( + command: list[str], + *, + check: bool, + cwd: Path, + env: dict[str, str], + ) -> SimpleNamespace: + observed.update(command=command, check=check, cwd=cwd, env=env) + questions_path = Path(command[command.index("--questions") + 1]) + result_path = Path(command[command.index("--result") + 1]) + assert questions_path.read_text(encoding="utf-8") == "" + result_path.write_text( + json.dumps({"answers": {}, "runtime": {"ready": True}}), + encoding="utf-8", + ) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(demo.subprocess, "run", fake_run) + + answers, runtime = _run_temporal_memory_candidate(video, (), candidate_root, 25) + + child_env = observed["env"] + assert isinstance(child_env, dict) + assert child_env["DIMOS_RUN_LOG_DIR"] == str(candidate_root / "run-logs") + assert os.environ["DIMOS_RUN_LOG_DIR"] == str(outside) + assert answers == {} + assert runtime == {"ready": True} + assert not outside.exists() + + def test_demo_uses_ephemeral_candidate_root_outside_teacher_output( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From 1f8a0911bd9246360273834139f3e42e273098a2 Mon Sep 17 00:00:00 2001 From: fengweit <2412561+fengweit@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:42:59 +0800 Subject: [PATCH 09/17] docs(benchmark): add interviewer reproduction path --- dimos/benchmark/spatiotemporal/README.md | 129 ++++++++++++++++++ .../spatiotemporal/reproduce_reference.sh | 117 ++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100755 dimos/benchmark/spatiotemporal/reproduce_reference.sh diff --git a/dimos/benchmark/spatiotemporal/README.md b/dimos/benchmark/spatiotemporal/README.md index 1d8b3270a3..a30a7f4ada 100644 --- a/dimos/benchmark/spatiotemporal/README.md +++ b/dimos/benchmark/spatiotemporal/README.md @@ -13,6 +13,135 @@ The core contribution is the evaluator, not a new vision model: - an evaluator joins predictions to private answers and emits exact diagnostics; and - an evaluator-only HTML viewer makes every pseudo-label claim inspectable. +## 1. This PR: reproducible evaluator foundation + +### Interviewer fast path + +From a PR checkout, one script materializes assets, installs the project and prompt dependency, runs every focused quality gate, executes the real CPU demo, and prints the result locations: + +```bash +gh pr checkout 2989 +./dimos/benchmark/spatiotemporal/reproduce_reference.sh +``` + +If Git reports that the branch is already assigned to another worktree, enter the path shown by Git and run the same script there. If setup and tests already passed, rerun only the real demo with: + +```bash +./dimos/benchmark/spatiotemporal/reproduce_reference.sh --skip-setup --skip-tests +``` + +Then open: + +```bash +open .artifacts/spatiotemporal-video-qa/evidence-viewer/index.html +``` + +A reviewer should establish the feature in this order: + +1. **Reproduce:** run the script and confirm the summary gates pass. +2. **See:** inspect the annotated evidence viewer rather than trusting aggregate scores. +3. **Verify privacy:** compare `bundle-a/public/` with `bundle-a/oracle/`. +4. **Verify replay:** confirm `bundle-a` and `bundle-b` have the same logical digest. +5. **Understand scope:** treat YOLO-E as a pseudo-label teacher and the bundled candidate as a plumbing smoke test. + +### What the PR proposes + +The PR establishes a reusable evaluator contract: + +```text +video + -> sampled observations + -> canonical relation facts + -> sample-aware intervals + -> public questions + private answers/evidence + -> isolated candidate predictions + -> exact report + inspectable evidence +``` + +The package is intentionally divided by responsibility: + +```text +contracts models.py, utilities.py +teacher video_adapter.py, yoloe_adapter.py, relations.py, intervals.py +generation generation.py, observation_io.py, replay.py +release bundles.py, ports.py +candidate temporal_memory_answerer.py, candidate_worker.py +evaluation runner.py, scoring.py +review evidence_viewer.py +acceptance demo.py, reproduce_reference.sh, test_*.py +``` + +This PR stops at a deterministic and inspectable evaluator. It does not hide teacher defects, automatically promote generated pseudo-labels to ground truth, or claim that the smoke-candidate score measures visual intelligence. + +## 2. Further directions: from videos to gated self-improvement + +The evaluator can become the data engine for continuous improvement: + +```text +new videos + -> generated challenge episodes + -> quality and disagreement gates + -> candidate failure clustering + -> human review/correction + -> training-data export + -> candidate N+1 + -> frozen holdout evaluation +``` + +The next proposed components are: + +1. **Episode admission report:** detector confidence, track continuity, label consistency, relation contradictions, evidence coverage, and replay determinism. +2. **Three-set registry:** a frozen reviewed regression set, a reviewed improvement set, and an unreviewed challenge pool. +3. **Failure miner:** cluster spatial, temporal, identity, label, missing-detection, and long-horizon-memory failures. +4. **Review workflow:** accept, correct, or reject generated episodes using the existing evidence viewer as the starting interface. +5. **Training export:** emit only reviewed examples, with provenance back to video, teacher version, schema, and evidence. +6. **Promotion policy:** replace a candidate only when frozen accuracy improves, critical families do not regress, and privacy, latency, and resource gates pass. + +Automatically generated data may enter the challenge pool immediately, but it must not enter a trusted benchmark or training set without explicit quality gates. + +## 3. Production path + +Productionization should happen in explicit stages: + +### Stage A — repeatable local and CI evaluation + +- Pin model weights, dependencies, prompts, schema, and reference assets. +- Run package tests and a small reviewed fixture in CI. +- Publish manifests, summary, and viewer as build artifacts. +- Preserve candidate subprocess and public/private filesystem isolation. + +### Stage B — versioned episode service + +- Store immutable source-video hashes and schema-versioned bundles. +- Register teacher version, prompts, thresholds, and sampling schedule. +- Make generation idempotent by episode key. +- Add retention and access policies for private video and oracle evidence. + +### Stage C — quality-gated curation + +- Compute automatic admission metrics. +- Route uncertain identity, label, or relation cases to review. +- Record reviewer corrections separately from teacher output. +- Promote only reviewed episodes into frozen evaluation sets. + +### Stage D — continuous candidate evaluation + +- Evaluate every candidate against the same frozen set. +- Run new videos as a separate challenge stream. +- Track per-family accuracy, missing or invalid answers, latency, memory, and regressions. +- Keep oracle data inaccessible to candidate containers and services. + +### Stage E — controlled self-improvement + +- Select high-value reviewed failures for training. +- Train or tune a new candidate with complete data provenance. +- Compare against the incumbent on untouched frozen holdouts. +- Require policy approval before promotion and support immediate rollback. + +Production readiness therefore means more than deploying the demo: it requires trusted data admission, immutable versioning, observability, privacy controls, candidate promotion gates, and rollback. + +# Detailed implementation reference + ## What this demonstrates The reference demo exercises real: diff --git a/dimos/benchmark/spatiotemporal/reproduce_reference.sh b/dimos/benchmark/spatiotemporal/reproduce_reference.sh new file mode 100755 index 0000000000..218ed86a53 --- /dev/null +++ b/dimos/benchmark/spatiotemporal/reproduce_reference.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# Reproduce the reference spatiotemporal video-relation evaluation from a checkout. +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: reproduce_reference.sh [--skip-setup] [--skip-tests] + + --skip-setup Do not run LFS materialization, uv sync, model extraction, or CLIP setup. + --skip-tests Do not run pytest, Ruff, or mypy before the real demo. +EOF +} + +skip_setup=false +skip_tests=false +while (($#)); do + case "$1" in + --skip-setup) skip_setup=true ;; + --skip-tests) skip_tests=true ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac + shift +done + +for command in git uv; do + command -v "$command" >/dev/null || { + echo "Missing required command: $command" >&2 + exit 1 + } +done + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +if ! $skip_setup; then + command -v git-lfs >/dev/null || { + echo "Missing required command: git-lfs" >&2 + exit 1 + } + + git lfs install --local + git lfs pull --include='assets/simple_demo.mp4,data/.lfs/models_yoloe.tar.gz' + + python3 - <<'PY' +from pathlib import Path +for name in ("assets/simple_demo.mp4", "data/.lfs/models_yoloe.tar.gz"): + size = Path(name).stat().st_size + print(f"{name}: {size:,} bytes") + assert size > 1_000_000, f"{name} is still an LFS pointer" +PY + + uv sync --group lint + uv run python -c "from dimos.utils.data import get_data; print(get_data('models_yoloe'))" + + if ! uv run python -c 'import clip' >/dev/null 2>&1; then + uv pip install 'git+https://github.com/ultralytics/CLIP.git' + fi +fi + +if ! $skip_tests; then + uv run pytest dimos/benchmark/spatiotemporal -q + uv run ruff format --check dimos/benchmark/spatiotemporal + uv run ruff check dimos/benchmark/spatiotemporal + uv run --group lint mypy dimos/benchmark/spatiotemporal +fi + +uv run python -m dimos.benchmark.spatiotemporal.demo \ + --source-video assets/simple_demo.mp4 \ + --output-root .artifacts/spatiotemporal-video-qa \ + --duration-s 25 \ + --frame-stride 150 + +uv run python - <<'PY' +import hashlib +import json +from pathlib import Path + +root = Path(".artifacts/spatiotemporal-video-qa") +summary_path = root / "summary.json" +summary = json.loads(summary_path.read_text(encoding="utf-8")) +teacher = summary["teacher"] +candidate = summary["candidate"] +review = summary["review"] + +assert summary["video"]["duration_s"] == 25 +assert summary["video"]["frames"] == 750 +assert teacher["detector_repeat_equal"] is True +assert teacher["relation_facts"] > 0 +assert teacher["relation_intervals"] > 0 +assert teacher["questions"] == teacher["answers"] +assert candidate["candidate_used_oracle"] is False +assert candidate["baseline_kind"] == "public_only_plumbing_smoke_test" +assert candidate["visually_grounded"] is False +assert candidate["questions_answered"] == teacher["questions"] +assert candidate["status_counts"]["missing"] == 0 +assert candidate["status_counts"]["invalid"] == 0 +assert review["question_count"] == teacher["questions"] +assert (root / review["index_path"]).is_file() + +digest = hashlib.sha256(summary_path.read_bytes()).hexdigest() +print(f"SUMMARY_GATES=PASS sha256={digest}") +PY + +cat <<'EOF' + +Reproduction complete. + +Summary: + .artifacts/spatiotemporal-video-qa/summary.json + +Evidence viewer: + .artifacts/spatiotemporal-video-qa/evidence-viewer/index.html + +On macOS, open it with: + open .artifacts/spatiotemporal-video-qa/evidence-viewer/index.html +EOF From ac8fb65df1c17fd7cd66d962ba852eaa00027c59 Mon Sep 17 00:00:00 2001 From: fengweit <2412561+fengweit@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:08:14 +0800 Subject: [PATCH 10/17] docs(benchmark): add generated examples and proof gates --- dimos/benchmark/spatiotemporal/README.md | 89 +++++++++++++++++++ .../spatiotemporal/reproduce_reference.sh | 43 +++++++++ 2 files changed, 132 insertions(+) diff --git a/dimos/benchmark/spatiotemporal/README.md b/dimos/benchmark/spatiotemporal/README.md index a30a7f4ada..4d6fe1bd50 100644 --- a/dimos/benchmark/spatiotemporal/README.md +++ b/dimos/benchmark/spatiotemporal/README.md @@ -73,6 +73,95 @@ acceptance demo.py, reproduce_reference.sh, test_*.py This PR stops at a deterministic and inspectable evaluator. It does not hide teacher defects, automatically promote generated pseudo-labels to ground truth, or claim that the smoke-candidate score measures visual intelligence. +### Concrete examples from the verified reference video + +The examples below are copied from the generated schema-v2 artifacts, not invented documentation fixtures. + +#### Spatial positive and negative controls + +At sampled frame `0`, the teacher derives: + +```json +{ + "frame_id": 0, + "timestamp_s": 0.0, + "subject_id": "1", + "predicate": "left-of", + "object_id": "2", + "evidence_frame_ids": [0] +} +``` + +That one accepted fact produces a public positive question and a private answer: + +```json +{"text":"Did 1 ever appear left of 2?","predicate":"left-of","object_ids":["1","2"]} +{"expected":true,"evidence_frame_ids":[0]} +``` + +It also produces the opposite negative control. The public question remains answer-free; the private oracle cites the complete sample schedule to support episode-level absence: + +```json +{"text":"Did 1 ever appear right of 2?","predicate":"right-of","object_ids":["1","2"]} +{"expected":false,"evidence_frame_ids":[0,150,300,450,600]} +``` + +Across the reference run, 3 accepted canonical spatial facts produce 6 spatial questions: 3 positive and 3 negative. + +#### Temporal ordering + +The private intervals include: + +```text +frame 0 / 0.000 s: "1 left of 2" +frame 300 / 10.001 s: "1 above 3" +frame 600 / 20.002 s: "1 left of 8" +``` + +From those intervals, generation emits paired temporal checks. For example: + +```json +{"text":"Did \"1 left of 2\" happen before \"1 above 3\"?","predicate":"before"} +{"expected":true,"evidence_interval_ids":["interval@frame-0","interval@frame-300"]} + +{"text":"Did \"1 left of 2\" happen after \"1 above 3\"?","predicate":"after"} +{"expected":false,"evidence_interval_ids":["interval@frame-0","interval@frame-300"]} +``` + +The readable aliases above shorten stable SHA-256 interval IDs only for presentation; generated artifacts contain the full IDs. With 3 unanimously ordered relation pairs, the reference run produces 12 balanced temporal questions: before/after positives and their inverse negative controls. + +#### Public/private proof + +Candidate-visible `public/questions.jsonl` contains only question semantics: + +```json +{ + "episode_id": "office_robot_25s", + "question_kind": "spatial", + "predicate": "left-of", + "object_ids": ["1", "2"], + "reference_ids": [], + "text": "Did 1 ever appear left of 2?" +} +``` + +It does not contain `expected`, `evidence_frame_ids`, `evidence_interval_ids`, observations, facts, or intervals. The parent evaluator retains those fields under `oracle/`; it never serializes them into the reference candidate's explicit input or temporary root, and it uses them for scoring after candidate execution. + +### Proof matrix + +| Claim | Reproducible artifact | Reference evidence | +|---|---|---| +| A video automatically becomes an eval | `observations.jsonl`, `bundle-a/` | 5 sampled frames → 8 observations → 3 facts → 3 intervals → 18 questions | +| Spatial cases are balanced | public questions + private answers | 6 spatial questions: 3 true, 3 false | +| Temporal cases are strict and balanced | interval and answer JSONL | frames 0, 300, 600 → 12 temporal questions: 6 true, 6 false | +| Reference candidate input excludes answers | candidate subprocess and bundle layout | only copied video + public questions are serialized into candidate root | +| Replay is deterministic | `bundle-a`, `bundle-b`, `summary.json` | equal manifests and logical SHA-256 `07b7b998…eaaf0` | +| Results are inspectable | `evidence-viewer/index.html` | 18 oracle rows linked to 5 annotated evidence frames | +| The smoke score is not overclaimed | `summary.json` | `visually_grounded=false`, `score_interpretation=not_model_quality` | +| Teacher defects remain visible | evidence viewer and README | robot ID changes `2`→`3`, missed detections, oven/cabinet mislabeled `refrigerator` | + +The reproduction script verifies generated counts, balanced expected values, absence of answer/evidence fields from public question records, summary gates, and representative examples. This proves the trusted reference candidate's explicit-input boundary, evaluator mechanics, and repeatability. It does not provide an OS security sandbox: an untrusted candidate must run in a container or worker with repository and oracle paths unmounted. It also intentionally does not prove that the current pseudo-label teacher or smoke candidate is production-accurate. + ## 2. Further directions: from videos to gated self-improvement The evaluator can become the data engine for continuous improvement: diff --git a/dimos/benchmark/spatiotemporal/reproduce_reference.sh b/dimos/benchmark/spatiotemporal/reproduce_reference.sh index 218ed86a53..f6eab4bd91 100755 --- a/dimos/benchmark/spatiotemporal/reproduce_reference.sh +++ b/dimos/benchmark/spatiotemporal/reproduce_reference.sh @@ -98,6 +98,49 @@ assert candidate["status_counts"]["invalid"] == 0 assert review["question_count"] == teacher["questions"] assert (root / review["index_path"]).is_file() +questions = [ + json.loads(line) + for line in (root / "bundle-a/public/questions.jsonl") + .read_text(encoding="utf-8") + .splitlines() +] +answers = [ + json.loads(line) + for line in (root / "bundle-a/oracle/answers.jsonl") + .read_text(encoding="utf-8") + .splitlines() +] +assert len(questions) == len(answers) == teacher["questions"] +for question in questions: + assert "expected" not in question + assert "evidence_frame_ids" not in question + assert "evidence_interval_ids" not in question + +answers_by_id = {answer["question_id"]: answer for answer in answers} +for family in ("spatial", "temporal"): + expected = [ + answers_by_id[question["question_id"]]["expected"] + for question in questions + if question["question_kind"] == family + ] + assert expected.count(True) == expected.count(False) + +print("GENERATED_EXAMPLES") +for family, expected in (("spatial", True), ("spatial", False), ("temporal", True)): + question = next( + question + for question in questions + if question["question_kind"] == family + and answers_by_id[question["question_id"]]["expected"] is expected + ) + answer = answers_by_id[question["question_id"]] + print(f" [{family} expected={str(expected).lower()}] {question['text']}") + print( + " evidence_frames=" + f"{answer['evidence_frame_ids']} evidence_intervals=" + f"{answer['evidence_interval_ids']}" + ) + digest = hashlib.sha256(summary_path.read_bytes()).hexdigest() print(f"SUMMARY_GATES=PASS sha256={digest}") PY From 91de0ea29e5269bee7be6edb027c9a4aca5dbf72 Mon Sep 17 00:00:00 2001 From: fengweit <2412561+fengweit@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:10:59 +0800 Subject: [PATCH 11/17] feat(benchmark): surface robot motion evidence --- dimos/benchmark/spatiotemporal/README.md | 26 +- .../spatiotemporal/evidence_viewer.py | 244 +++++++++++++++--- .../spatiotemporal/test_evidence_viewer.py | 24 +- 3 files changed, 246 insertions(+), 48 deletions(-) diff --git a/dimos/benchmark/spatiotemporal/README.md b/dimos/benchmark/spatiotemporal/README.md index 4d6fe1bd50..a204a2b320 100644 --- a/dimos/benchmark/spatiotemporal/README.md +++ b/dimos/benchmark/spatiotemporal/README.md @@ -436,15 +436,23 @@ Visual inspection confirmed that the boxes are geometrically aligned where detec ## Five-minute reviewer walkthrough -1. Open `evidence-viewer/index.html`. -2. Confirm boxes and IDs follow the same physical objects across sampled frames. -3. Treat labels as pseudo-labels; note any semantically weak detection. -4. Check positive and negative spatial questions against the linked frames. -5. Check temporal wording and interval evidence. -6. Inspect `bundle-a/public/questions.jsonl`: it must contain no expected answers or evidence. -7. Inspect `bundle-a/oracle/answers.jsonl`: it must remain private. -8. Compare `bundle-a` and `bundle-b` manifests and the logical digest in `summary.json`. -9. Confirm `summary.json` labels the candidate as a non-visual plumbing smoke test. +1. Open `evidence-viewer/index.html` and confirm the header metrics match the reference run: 5 evidence frames, 4 track IDs, 3 relation intervals, 6 spatial and 12 temporal questions, and a balanced 9/9 oracle split. +2. Follow **One generated proof chain** from frame → derived relation → answer-free public question → private expected answer and evidence. This is the shortest proof of what the evaluator creates. +3. Read **Robot-motion verification** before claiming motion quality. A trustworthy motion claim requires all four checks: + - relation events are linked to evidence frames; + - relation intervals have strict temporal order; + - the robot keeps one identity across those events; and + - robot detections cover the evidence schedule sufficiently. +4. For this reference clip, confirm the honest verdict: relation ordering is evaluable, but the full robot-motion claim needs review because the robot changes ID `2` → `3` and appears in only 2/5 evidence frames. +5. Use **What changed, and when** to inspect the sampled sequence: `1 left of 2` at 0.000 s, `1 above 3` at 10.001 s, and `1 left of 8` at 20.002 s. The 5.001 s and 15.002 s gaps remain visible and are not interpolated. +6. Inspect all five annotated frames. Confirm box placement, label semantics, confidence, identity continuity, and the relation chip shown for each accepted event. Click any frame for the full-resolution lightbox. +7. Filter **Questions and private oracle** by spatial, temporal, true, or false. Follow evidence chips back to frames and verify both positive cases and inverse negative controls. +8. Read **Reusable motion-evaluation patterns** to connect the same contract to locomotion/patrol, manipulation, long-horizon memory, and release regression gates. +9. Inspect `bundle-a/public/questions.jsonl`: it must contain no expected answers or evidence. Keep `bundle-a/oracle/answers.jsonl` private. +10. Compare `bundle-a` and `bundle-b` manifests and the logical digest in `summary.json` to verify deterministic replay. +11. Confirm `summary.json` labels the candidate as a non-visual plumbing smoke test. + +The viewer intentionally distinguishes **relation-order evidence** from a stronger **continuous robot-motion claim**. The current evaluator can prove sampled spatial predicates and strict before/after ordering. Production trajectory claims additionally need stable identity, higher detection coverage, denser sampling, and task-specific motion predicates or pose data. ## Quality gates diff --git a/dimos/benchmark/spatiotemporal/evidence_viewer.py b/dimos/benchmark/spatiotemporal/evidence_viewer.py index 44fd5005ab..a587a2b14c 100644 --- a/dimos/benchmark/spatiotemporal/evidence_viewer.py +++ b/dimos/benchmark/spatiotemporal/evidence_viewer.py @@ -18,6 +18,7 @@ from dataclasses import dataclass from hashlib import sha256 from html import escape +from itertools import pairwise from pathlib import Path import shutil from tempfile import mkdtemp @@ -197,8 +198,90 @@ def _render_html( for observation in bundle.observations: observations_by_frame[observation.frame_id].append(observation) + questions = sorted(bundle.questions, key=lambda item: item.question_id) + evidence_frame_ids = sorted( + {frame_id for frame_ids in evidence_by_question.values() for frame_id in frame_ids} + ) + episode_id = questions[0].episode_id if questions else "unknown" + object_ids = sorted({observation.object_id for observation in bundle.observations}) + spatial_count = sum(question.question_kind.value == "spatial" for question in questions) + temporal_count = len(questions) - spatial_count + positive_count = sum(answer.expected for answer in bundle.answers) + + timestamps_by_frame: dict[int, float] = {} + for observation in bundle.observations: + timestamps_by_frame.setdefault(observation.frame_id, observation.timestamp_s) + for interval in bundle.relation_intervals: + timestamps_by_frame.setdefault(interval.start_frame_id, interval.start_timestamp_s) + timestamps_by_frame.setdefault(interval.end_frame_id, interval.end_timestamp_s) + + relations_by_frame: dict[int, list[str]] = defaultdict(list) + for interval in bundle.relation_intervals: + relation = ( + f"{interval.subject_id} {interval.predicate.value.replace('-', ' ')} " + f"{interval.object_id}" + ) + for frame_id in interval.evidence_frame_ids: + relations_by_frame[frame_id].append(relation) + + robot_observations = [ + observation + for observation in bundle.observations + if "robot" in observation.label.casefold() + ] + robot_ids = sorted({observation.object_id for observation in robot_observations}) + robot_frames = sorted({observation.frame_id for observation in robot_observations}) + robot_coverage = len(robot_frames) / len(evidence_frame_ids) if evidence_frame_ids else 0.0 + referenced_interval_ids = { + interval_id for answer in bundle.answers for interval_id in answer.evidence_interval_ids + } + displayed_frames = set(evidence_frame_ids) + linked_intervals = [ + interval + for interval in bundle.relation_intervals + if interval.interval_id in referenced_interval_ids + and bool(displayed_frames.intersection(interval.evidence_frame_ids)) + ] + relation_status = ( + "pass" + if linked_intervals and len(linked_intervals) == len(bundle.relation_intervals) + else "review" + ) + ordered_intervals = sorted( + linked_intervals, + key=lambda interval: (interval.start_timestamp_s, interval.interval_id), + ) + strict_order = len(ordered_intervals) > 1 and all( + left.end_frame_id < right.start_frame_id and left.end_timestamp_s < right.start_timestamp_s + for left, right in pairwise(ordered_intervals) + ) + proof_question = next( + ( + question + for question in questions + if question.question_kind.value == "spatial" + and answers[question.question_id].expected + and evidence_by_question.get(question.question_id) + ), + None, + ) + if proof_question is not None and len(proof_question.object_ids) == 2: + proof_frame = evidence_by_question[proof_question.question_id][0] + subject_id, object_id = proof_question.object_ids + proof_relation = ( + f"{subject_id} {proof_question.predicate.value.replace('-', ' ')} {object_id}" + ) + proof_chain = ( + f"
01 · evidenceframe {proof_frame}
" + f"
02 · derived fact{escape(proof_relation)}
" + f"
03 · public test{escape(proof_question.text)}
" + f"
04 · private oracletrue · evidence f{proof_frame}
" + ) + else: + proof_chain = "

No positive spatial proof is available.

" + rows: list[str] = [] - for question in sorted(bundle.questions, key=lambda item: item.question_id): + for question in questions: try: answer = answers[question.question_id] except KeyError as error: @@ -207,67 +290,152 @@ def _render_html( ) from error frame_ids = evidence_by_question.get(question.question_id, ()) links = " ".join( - f'frame {frame_id}' + f'f{frame_id}' for frame_id in frame_ids ) + expected = str(answer.expected).lower() rows.append( - "" - f"{escape(question.question_id)}" - f"{escape(question.text)}" - f"{str(answer.expected).lower()}" - f"{links or 'none'}" + f'' + f'' + f"{question.question_kind.value}" + f"{escape(question.text)}" + f'…{escape(question.question_id[-10:])}' + f'{expected}' + f"{links or 'none'}" "" ) + timeline: list[str] = [] cards: list[str] = [] - evidence_frame_ids = sorted( - {frame_id for frame_ids in evidence_by_question.values() for frame_id in frame_ids} - ) for frame_id in evidence_frame_ids: - labels = ", ".join( - f"{observation.object_id}: {observation.label}" - for observation in sorted( - observations_by_frame[frame_id], key=lambda item: item.object_id + observations = sorted(observations_by_frame[frame_id], key=lambda item: item.object_id) + timestamp_s = timestamps_by_frame.get(frame_id) + timestamp = f"{timestamp_s:.3f}s" if timestamp_s is not None else "time unknown" + relations = ( + "".join( + f'{escape(relation)}' + for relation in sorted(relations_by_frame[frame_id]) ) + or 'no accepted relation' + ) + timeline.append( + f'" + ) + labels = ( + ", ".join( + f"#{observation.object_id} {observation.label} ({observation.confidence:.2f})" + for observation in observations + ) + or "No accepted detections" ) cards.append( - "
" - f'' - f'Evidence frame {frame_id}' - f"
Frame {frame_id}: {escape(labels)}
" + f'
' + f'' + f"
{timestamp}frame {frame_id}
" + f"

{escape(labels)}

{relations}
" "
" ) + identity_status = "pass" if len(robot_ids) == 1 else "review" + identity_detail = ( + f"one robot track ({robot_ids[0]})" + if len(robot_ids) == 1 + else ( + f"robot label spans IDs {', '.join(robot_ids)}" + if robot_ids + else "no robot-labeled detection" + ) + ) + coverage_status = "pass" if robot_coverage >= 0.8 else "review" + if relation_status == "review" or not strict_order: + motion_verdict = "Motion evidence incomplete; review required" + elif identity_status == "review" or coverage_status == "review": + motion_verdict = "Relation-order eval ready; motion claim needs review" + else: + motion_verdict = "Motion evidence ready for review" + return ( """ -Spatiotemporal relationship evidence +Video → Eval evidence -

Spatiotemporal relationship evidence

-

Review required: These are teacher pseudo-labels, not ground truth. Inspect boxes, identities, labels, and relationships before using the episode for model-quality claims.

-

Questions and private oracle

- -""" - + "\n".join(rows) - + """ -
IDQuestionExpectedEvidence
-

Annotated evidence frames

-
""" - + "\n".join(cards) - + """
- - -""" +
+
Evaluator-only review surface

Video → relationship eval evidence

Trace how sampled robot-video observations become spatial and temporal tests, then inspect the exact frames supporting each oracle answer.

EPISODE""" + + escape(episode_id) + + """
+
+
""" + + str(len(evidence_frame_ids)) + + """evidence frames
""" + + str(len(object_ids)) + + """tracked IDs
""" + + str(len(bundle.relation_intervals)) + + """relation intervals
""" + + str(spatial_count) + + """ / """ + + str(temporal_count) + + """spatial / temporal
""" + + str(positive_count) + + """ / """ + + str(len(bundle.answers) - positive_count) + + """true / false
+

Turns observed relations into replayable tests

01Video
02Sample
03Track
04Relations
05Questions
06Score + evidence
+

From a sampled frame to a scored evaluation

""" + + proof_chain + + """
+

""" + + escape(motion_verdict) + + """

""" + + relation_status + + """
Relation events derived
""" + + f"{len(linked_intervals)}/{len(bundle.relation_intervals)}" + + """ intervals are referenced by oracle answers and linked to displayed frames.
""" + + ("pass" if strict_order else "review") + + """
Temporal order
Strict non-overlap across the displayed relation sequence.
""" + + identity_status + + """
Robot identity continuity
""" + + escape(identity_detail) + + """.
""" + + coverage_status + + """
Robot detection coverage
""" + + f"{len(robot_frames)}/{len(evidence_frame_ids)} evidence frames ({robot_coverage:.0%})" + + """.
+

What changed, and when

Each stop is a sampled evidence frame. Relation labels come from private accepted intervals; gaps remain visible rather than interpolated.

""" + + "".join(timeline) + + """
+

Reusable motion-evaluation patterns

The same contracts can evaluate new videos without changing candidate/oracle separation.

Locomotion & patrolVerify before/after ordering around tracked landmarks and objects.
ManipulationTurn above/below and left/right object transitions into regression cases.
Long-horizon memoryTest whether a candidate recalls relation events separated in time.
Model releasesReplay frozen bundles and block spatial or temporal regressions.
+

Annotated sampled frames

Click a frame to inspect full resolution. Colors are stable per object ID.

+

Questions and private oracle

Filter by family or expected value; evidence links jump back to the supporting frames.

""" + + "".join(rows) + + """
FamilyQuestionExpectedEvidence
+
+""" ) diff --git a/dimos/benchmark/spatiotemporal/test_evidence_viewer.py b/dimos/benchmark/spatiotemporal/test_evidence_viewer.py index 99ab09ac40..4abc7599d5 100644 --- a/dimos/benchmark/spatiotemporal/test_evidence_viewer.py +++ b/dimos/benchmark/spatiotemporal/test_evidence_viewer.py @@ -138,6 +138,14 @@ def capture_factory(_: str) -> _FakeCapture: assert outputs[0] == outputs[1] html = (roots[0] / "index.html").read_text(encoding="utf-8") assert "teacher pseudo-labels, not ground truth" in html + assert "One generated proof chain" in html + assert "Robot-motion verification" in html + assert "Motion evidence incomplete; review required" in html + assert 'aria-pressed="true">All' in html + assert 'aria-pressed="false">Spatial' in html + assert "Relation timeline" in html + assert "Future-project leverage" in html + assert "Questions and private oracle" in html assert "Did <oven> ever appear left of robot?" in html assert "<oven>" in html assert "" not in html @@ -175,7 +183,21 @@ def test_resolves_temporal_interval_to_sparse_nonzero_frame( ), ), observations=base.observations, - relation_intervals=(SimpleNamespace(interval_id=interval_id, evidence_frame_ids=(2,)),), + relation_intervals=( + SimpleNamespace( + interval_id=interval_id, + relation_id=f"relation_{'2' * 64}", + episode_id="episode_1", + subject_id="oven", + predicate=SpatialPredicate.LEFT_OF, + object_id="robot", + start_frame_id=2, + end_frame_id=2, + start_timestamp_s=0.2, + end_timestamp_s=0.2, + evidence_frame_ids=(2,), + ), + ), ), ) monkeypatch.setattr(cv2, "VideoCapture", lambda _: _FakeCapture(frame_count=3)) From 1958bf28e0076f70c3cbf6b2e4459d9e2356fdc8 Mon Sep 17 00:00:00 2001 From: fengweit <2412561+fengweit@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:19:49 +0800 Subject: [PATCH 12/17] docs(benchmark): add visual evaluation walkthrough --- .gitattributes | 2 ++ .../evidence-viewer-overview.jpg | Bin 0 -> 225691 bytes .../relationship-progression.jpg | Bin 0 -> 186145 bytes 3 files changed, 2 insertions(+) create mode 100644 docs/assets/spatiotemporal/evidence-viewer-overview.jpg create mode 100644 docs/assets/spatiotemporal/relationship-progression.jpg diff --git a/.gitattributes b/.gitattributes index f2a548eec1..22a1dd4d8f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -22,3 +22,5 @@ docs/capabilities/mapping/assets/** filter=lfs diff=lfs merge=lfs -text # Mintlify deploy does not fetch Git LFS; keep site branding as normal blobs. docs/assets/dimensional-logo-master-transparent.png -filter -diff -merge docs/assets/favicon.png -filter -diff -merge +# Keep small PR walkthrough images directly renderable without private LFS credentials. +docs/assets/spatiotemporal/** -filter -diff -merge diff --git a/docs/assets/spatiotemporal/evidence-viewer-overview.jpg b/docs/assets/spatiotemporal/evidence-viewer-overview.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d1bc031e17690f94c516c03c88b15ad4db7fea5c GIT binary patch literal 225691 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
C-Es!GI-{c+>1H~Dmm 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 z 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ⅇ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

q{% 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�Yf`umcr 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 Date: Thu, 16 Jul 2026 13:29:31 +0800 Subject: [PATCH 13/17] chore: align asset attributes with main --- .gitattributes | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitattributes b/.gitattributes index 22a1dd4d8f..f06707cb4a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -24,3 +24,8 @@ docs/assets/dimensional-logo-master-transparent.png -filter -diff -merge docs/assets/favicon.png -filter -diff -merge # Keep small PR walkthrough images directly renderable without private LFS credentials. docs/assets/spatiotemporal/** -filter -diff -merge +# DimSim scene data and agent model +misc/DimSim/public/sims/*.json filter=lfs diff=lfs merge=lfs -text +misc/DimSim/public/embodiment/*.glb filter=lfs diff=lfs merge=lfs -text +misc/DimSim/scenes/**/*.glb filter=lfs diff=lfs merge=lfs -text +misc/DimSim/scenes/**/*.gltf filter=lfs diff=lfs merge=lfs -text From 419ddda34e2718b98a6b9dd915d0a6cf54aad926 Mon Sep 17 00:00:00 2001 From: fengweit <2412561+fengweit@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:32:21 +0800 Subject: [PATCH 14/17] chore: place walkthrough asset override after main rules --- .gitattributes | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitattributes b/.gitattributes index f06707cb4a..8acc24341b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -22,10 +22,10 @@ docs/capabilities/mapping/assets/** filter=lfs diff=lfs merge=lfs -text # Mintlify deploy does not fetch Git LFS; keep site branding as normal blobs. docs/assets/dimensional-logo-master-transparent.png -filter -diff -merge docs/assets/favicon.png -filter -diff -merge -# Keep small PR walkthrough images directly renderable without private LFS credentials. -docs/assets/spatiotemporal/** -filter -diff -merge # DimSim scene data and agent model misc/DimSim/public/sims/*.json filter=lfs diff=lfs merge=lfs -text misc/DimSim/public/embodiment/*.glb filter=lfs diff=lfs merge=lfs -text misc/DimSim/scenes/**/*.glb filter=lfs diff=lfs merge=lfs -text misc/DimSim/scenes/**/*.gltf filter=lfs diff=lfs merge=lfs -text +# Keep small PR walkthrough images directly renderable without private LFS credentials. +docs/assets/spatiotemporal/** -filter -diff -merge From f712482211ed855743d6323267af1f9b3638799e Mon Sep 17 00:00:00 2001 From: fengweit <2412561+fengweit@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:33:08 +0800 Subject: [PATCH 15/17] chore: scope walkthrough asset attributes locally --- .gitattributes | 9 +-------- docs/assets/spatiotemporal/.gitattributes | 2 ++ 2 files changed, 3 insertions(+), 8 deletions(-) create mode 100644 docs/assets/spatiotemporal/.gitattributes diff --git a/.gitattributes b/.gitattributes index 8acc24341b..738a5020c1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,7 +1,7 @@ # Handle line endings automatically for files Git considers text, # converting them to LF on checkout. * text=auto eol=lf -# Ensure Python files always use LF for line endings. +# Ensure Python files always use LF line endings. *.py text eol=lf # Treat designated file types as binary and do not alter their contents or line endings. *.png filter=lfs diff=lfs merge=lfs -text binary @@ -22,10 +22,3 @@ docs/capabilities/mapping/assets/** filter=lfs diff=lfs merge=lfs -text # Mintlify deploy does not fetch Git LFS; keep site branding as normal blobs. docs/assets/dimensional-logo-master-transparent.png -filter -diff -merge docs/assets/favicon.png -filter -diff -merge -# DimSim scene data and agent model -misc/DimSim/public/sims/*.json filter=lfs diff=lfs merge=lfs -text -misc/DimSim/public/embodiment/*.glb filter=lfs diff=lfs merge=lfs -text -misc/DimSim/scenes/**/*.glb filter=lfs diff=lfs merge=lfs -text -misc/DimSim/scenes/**/*.gltf filter=lfs diff=lfs merge=lfs -text -# Keep small PR walkthrough images directly renderable without private LFS credentials. -docs/assets/spatiotemporal/** -filter -diff -merge diff --git a/docs/assets/spatiotemporal/.gitattributes b/docs/assets/spatiotemporal/.gitattributes new file mode 100644 index 0000000000..1eb26f3eb1 --- /dev/null +++ b/docs/assets/spatiotemporal/.gitattributes @@ -0,0 +1,2 @@ +# Keep these small PR walkthrough images directly renderable without private LFS credentials. +*.jpg -filter -diff -merge -text From b88de8ec93f0679676c6ec33b8fcc184a053e2a5 Mon Sep 17 00:00:00 2001 From: fengweit <2412561+fengweit@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:33:35 +0800 Subject: [PATCH 16/17] chore: restore root asset attributes --- .gitattributes | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index 738a5020c1..f2a548eec1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,7 +1,7 @@ # Handle line endings automatically for files Git considers text, # converting them to LF on checkout. * text=auto eol=lf -# Ensure Python files always use LF line endings. +# Ensure Python files always use LF for line endings. *.py text eol=lf # Treat designated file types as binary and do not alter their contents or line endings. *.png filter=lfs diff=lfs merge=lfs -text binary From a4fb14069c67c0dd7b83ef17c927356bf9183e4b Mon Sep 17 00:00:00 2001 From: fengweit <2412561+fengweit@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:54:01 +0800 Subject: [PATCH 17/17] docs(benchmark): clarify evaluator setup and reuse --- dimos/benchmark/spatiotemporal/README.md | 47 ++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/dimos/benchmark/spatiotemporal/README.md b/dimos/benchmark/spatiotemporal/README.md index a204a2b320..b1427b2928 100644 --- a/dimos/benchmark/spatiotemporal/README.md +++ b/dimos/benchmark/spatiotemporal/README.md @@ -17,6 +17,19 @@ The core contribution is the evaluator, not a new vision model: ### Interviewer fast path +#### Prerequisites + +A fresh checkout needs: + +- `git` and `git-lfs`; +- access to the DimOS LFS endpoint for `assets/simple_demo.mp4` and the YOLO-E archive; +- `uv`; and +- `gh` only when using the `gh pr checkout` command below. + +The reference path runs on macOS or Linux and supports CPU execution. GitHub repository access does not necessarily grant access to the separately configured DimOS LFS endpoint. + +#### Reproduce the reference episode + From a PR checkout, one script materializes assets, installs the project and prompt dependency, runs every focused quality gate, executes the real CPU demo, and prints the result locations: ```bash @@ -30,12 +43,46 @@ If Git reports that the branch is already assigned to another worktree, enter th ./dimos/benchmark/spatiotemporal/reproduce_reference.sh --skip-setup --skip-tests ``` +A successful run prints both: + +```text +SUMMARY_GATES=PASS +Reproduction complete. +``` + Then open: ```bash open .artifacts/spatiotemporal-video-qa/evidence-viewer/index.html ``` +On Linux, open the same `index.html` in a browser. + +#### Try another video + +After the initial setup, reuse the pipeline with a different video: + +```bash +uv run python -m dimos.benchmark.spatiotemporal.demo \ + --source-video /path/to/video.mp4 \ + --output-root .artifacts/my-video-eval \ + --duration-s 25 \ + --frame-stride 150 +``` + +Use a regular 15–30-second video that OpenCV can decode. Useful episodes contain multiple visible, spatially separated objects and at least one relationship change. Arbitrary videos are not guaranteed to yield accepted relations or questions because YOLO-E labels and tracks remain teacher pseudo-labels. + +Open the custom result at: + +```text +.artifacts/my-video-eval/evidence-viewer/index.html +``` + +#### Common setup failures + +- **LFS authentication or pointer-sized assets:** obtain access to the configured DimOS LFS endpoint, then rerun `git lfs pull --include='assets/simple_demo.mp4,data/.lfs/models_yoloe.tar.gz'`. The reproduction script rejects either asset if it remains an LFS pointer. +- **No relations or questions from a custom video:** choose a clearer clip or adjust its duration and sampling stride. Inspect detection labels, confidence, identity continuity, and object separation rather than weakening privacy or determinism checks. + A reviewer should establish the feature in this order: 1. **Reproduce:** run the script and confirm the summary gates pass.

&OO4!^YLy<{siuNDT&yELW*njXQ2no-@Nq2F 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 IK;W$0WE3k>S+{#6IH`v&+1_ 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