diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..bbd60cf --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,37 @@ +# Svrnty Vision Context + +`svrnty-vision` is a child-local Cortex OS VISION package candidate for visual +perception and visual production tools. It is not Core authority, not Seed +installed, not Runtime, not Profile Exposure, and not provider admission. + +## Terms + +Visual Perception Package Candidate +: A child-local candidate package that owns pixel/media perception surfaces under + canonical sense `VISION`. + +VISION Sense Family +: The canonical sense family for seeing. It can include textual/source reading + through `/research` and pixel/media perception through `/vision`. + +Visual Evidence +: A normalized output record produced by a VISION tool. It discloses package, + tool, source, provider mode, retention, observed content, extracted claims, + confidence, caveats, timestamp, and validation status. + +Vision Tool Candidate +: A granular tool inside the visual perception package. Granting the package does + not grant every tool. + +Current Route Adapter +: An existing HTTP route that can be mapped to a future Cortex OS tool id without + changing the implementation stack. + +Research Handoff +: Research may cite or synthesize Visual Evidence that `/vision` already + produced, but `/vision` does not perform research synthesis, web search, page + fetch, PDF extraction, or deep research workflows. + +BTE Compatibility Surface +: Existing route names and BTE-shaped behavior stay usable while the package + candidate is documented for Cortex OS. diff --git a/README.md b/README.md index c9dfb82..7dc6f1d 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,21 @@ libraries — Pillow/colorthief and rembg respectively. They land in 4b. ## Status -**Phase 4a (this commit):** scaffold only. All four endpoints return -HTTP 501 Not Implemented. `/healthz` returns 200. +Current implementation: all four listed endpoints exist. `/vlm/analyze` proxies +to Qwen3-VL through the configured VLM endpoint, `/flux/render` proxies to +ComfyUI FLUX, `/palette/extract` runs in process, `/rembg/cutout` runs in +process, and `/healthz` returns 200. -Phase 4b will port the real logic. Phase 4c deletes the corresponding -.NET code from BTE. Phase 4d wires BTE to call svrnty-vision over HTTP. -See `/home/svrnty/workspaces/hermes/sot/01-ROADMAP/BTE-REFACTOR-EXECUTION-PLAN.md`. +## Cortex OS Package Candidate + +`svrnty-vision` is also documented as a child-local Visual Perception Package +Candidate under canonical sense `VISION`. Existing BTE-shaped HTTP routes remain +current route adapters; exact Cortex OS candidate tool ids, grant rules, host +adapter candidates, and Visual Evidence contract live in `docs/` and +`candidate-manifests/`. + +This does not grant Core promotion, Seed installation, Runtime startup, Profile +Exposure, provider admission, or wildcard tool access. ## Run diff --git a/WORKBOARD.yaml b/WORKBOARD.yaml index a2cb073..cf7692c 100644 --- a/WORKBOARD.yaml +++ b/WORKBOARD.yaml @@ -4,3 +4,13 @@ items: status: candidate source: README.md owner: jp + - id: SVRNTY-VISION-WORK-002 + title: Generic VISION Package Candidate And Visual Evidence Proof + status: complete + source: outputs/2026-06-06-svrnty-vision-work-002-package-candidate-validation.md + owner: jp + - id: SVRNTY-VISION-WORK-003 + title: Vision Package Candidate Sandcastle + status: ready-for-agent + source: outputs/sandcastle-vision-package-candidate/README.md + owner: jp diff --git a/candidate-manifests/host-adapters/vision-claude-code-adapter.json b/candidate-manifests/host-adapters/vision-claude-code-adapter.json new file mode 100644 index 0000000..e586ba8 --- /dev/null +++ b/candidate-manifests/host-adapters/vision-claude-code-adapter.json @@ -0,0 +1,60 @@ +{ + "schema_version": "vision.host_adapter_candidate.v1", + "adapter_id": "vision-claude-code-adapter", + "authority": "child-local", + "status": "candidate_only_not_seed_installed", + "host_runtime": "claude-code", + "host_runtime_class": "claude-code", + "package_id": "visual-perception-package-candidate", + "adapter_role": "thin_access_adapter", + "seed_artifact_target": "host-adapters/vision-claude-code-adapter.json", + "wildcard_grant_allowed": false, + "current_tool_candidates": [ + "vision.image_analyze", + "vision.image_generate", + "vision.palette_extract", + "vision.background_cutout" + ], + "planned_tool_candidates": [ + "vision.ocr_read", + "vision.screenshot_observe", + "vision.browser_observe", + "vision.document_layout_read", + "vision.chart_read", + "vision.table_read", + "vision.diagram_read", + "vision.object_detect", + "vision.visual_ground", + "vision.segment", + "vision.video_read", + "vision.image_edit" + ], + "disclosure_contract": { + "provider_mode_required": true, + "retention_required": true, + "visual_evidence_required": true + }, + "allowed_effects": [ + "analyze_image_input", + "generate_image_output", + "extract_palette", + "remove_background", + "return_visual_evidence" + ], + "forbidden_effects": [ + "research_synthesis", + "textual_web_search", + "textual_page_fetch", + "profile_exposure", + "runtime_start", + "provider_admission", + "wildcard_tool_exposure" + ], + "non_authorization": { + "core_promotion": false, + "seed_installation": false, + "runtime_start": false, + "profile_exposure": false, + "provider_admission": false + } +} diff --git a/candidate-manifests/host-adapters/vision-codex-cli-adapter.json b/candidate-manifests/host-adapters/vision-codex-cli-adapter.json new file mode 100644 index 0000000..2e2797e --- /dev/null +++ b/candidate-manifests/host-adapters/vision-codex-cli-adapter.json @@ -0,0 +1,60 @@ +{ + "schema_version": "vision.host_adapter_candidate.v1", + "adapter_id": "vision-codex-cli-adapter", + "authority": "child-local", + "status": "candidate_only_not_seed_installed", + "host_runtime": "codex-cli", + "host_runtime_class": "codex-cli", + "package_id": "visual-perception-package-candidate", + "adapter_role": "thin_access_adapter", + "seed_artifact_target": "host-adapters/vision-codex-cli-adapter.json", + "wildcard_grant_allowed": false, + "current_tool_candidates": [ + "vision.image_analyze", + "vision.image_generate", + "vision.palette_extract", + "vision.background_cutout" + ], + "planned_tool_candidates": [ + "vision.ocr_read", + "vision.screenshot_observe", + "vision.browser_observe", + "vision.document_layout_read", + "vision.chart_read", + "vision.table_read", + "vision.diagram_read", + "vision.object_detect", + "vision.visual_ground", + "vision.segment", + "vision.video_read", + "vision.image_edit" + ], + "disclosure_contract": { + "provider_mode_required": true, + "retention_required": true, + "visual_evidence_required": true + }, + "allowed_effects": [ + "analyze_image_input", + "generate_image_output", + "extract_palette", + "remove_background", + "return_visual_evidence" + ], + "forbidden_effects": [ + "research_synthesis", + "textual_web_search", + "textual_page_fetch", + "profile_exposure", + "runtime_start", + "provider_admission", + "wildcard_tool_exposure" + ], + "non_authorization": { + "core_promotion": false, + "seed_installation": false, + "runtime_start": false, + "profile_exposure": false, + "provider_admission": false + } +} diff --git a/candidate-manifests/host-adapters/vision-pi-code-adapter.json b/candidate-manifests/host-adapters/vision-pi-code-adapter.json new file mode 100644 index 0000000..0f34605 --- /dev/null +++ b/candidate-manifests/host-adapters/vision-pi-code-adapter.json @@ -0,0 +1,60 @@ +{ + "schema_version": "vision.host_adapter_candidate.v1", + "adapter_id": "vision-pi-code-adapter", + "authority": "child-local", + "status": "candidate_only_not_seed_installed", + "host_runtime": "pi-code", + "host_runtime_class": "pi-code", + "package_id": "visual-perception-package-candidate", + "adapter_role": "thin_access_adapter", + "seed_artifact_target": "host-adapters/vision-pi-code-adapter.json", + "wildcard_grant_allowed": false, + "current_tool_candidates": [ + "vision.image_analyze", + "vision.image_generate", + "vision.palette_extract", + "vision.background_cutout" + ], + "planned_tool_candidates": [ + "vision.ocr_read", + "vision.screenshot_observe", + "vision.browser_observe", + "vision.document_layout_read", + "vision.chart_read", + "vision.table_read", + "vision.diagram_read", + "vision.object_detect", + "vision.visual_ground", + "vision.segment", + "vision.video_read", + "vision.image_edit" + ], + "disclosure_contract": { + "provider_mode_required": true, + "retention_required": true, + "visual_evidence_required": true + }, + "allowed_effects": [ + "analyze_image_input", + "generate_image_output", + "extract_palette", + "remove_background", + "return_visual_evidence" + ], + "forbidden_effects": [ + "research_synthesis", + "textual_web_search", + "textual_page_fetch", + "profile_exposure", + "runtime_start", + "provider_admission", + "wildcard_tool_exposure" + ], + "non_authorization": { + "core_promotion": false, + "seed_installation": false, + "runtime_start": false, + "profile_exposure": false, + "provider_admission": false + } +} diff --git a/candidate-manifests/vision-package-candidate.json b/candidate-manifests/vision-package-candidate.json new file mode 100644 index 0000000..557d86e --- /dev/null +++ b/candidate-manifests/vision-package-candidate.json @@ -0,0 +1,84 @@ +{ + "schema_version": "vision.package_candidate.v1", + "package_id": "visual-perception-package-candidate", + "workspace": "svrnty-vision", + "authority": "child-local", + "status": "candidate_only_not_seed_installed", + "canonical_sense": "VISION", + "package_role": "generic_visual_perception_and_production", + "current_route_adapters": [ + { + "route": "POST /vlm/analyze", + "tool_id": "vision.image_analyze", + "capability": "vlm_image_analysis" + }, + { + "route": "POST /flux/render", + "tool_id": "vision.image_generate", + "capability": "image_generation" + }, + { + "route": "POST /palette/extract", + "tool_id": "vision.palette_extract", + "capability": "palette_extraction" + }, + { + "route": "POST /rembg/cutout", + "tool_id": "vision.background_cutout", + "capability": "background_cutout" + } + ], + "current_tool_candidates": [ + "vision.image_analyze", + "vision.image_generate", + "vision.palette_extract", + "vision.background_cutout" + ], + "planned_tool_candidates": [ + "vision.ocr_read", + "vision.screenshot_observe", + "vision.browser_observe", + "vision.document_layout_read", + "vision.chart_read", + "vision.table_read", + "vision.diagram_read", + "vision.object_detect", + "vision.visual_ground", + "vision.segment", + "vision.video_read", + "vision.image_edit" + ], + "owned_capability_classes": [ + "pixel_media_perception", + "visual_evidence_production", + "image_generation", + "image_editing", + "screenshot_and_browser_visual_observation", + "layout_chart_table_diagram_visual_reading" + ], + "forbidden_research_owned_capabilities": [ + "web_search", + "fetch_page", + "extract_pdf", + "deep_research", + "research_synthesis", + "capsule_writing" + ], + "research_handoff": { + "may_produce_visual_evidence_for_research": true, + "may_perform_research_synthesis": false, + "handoff_contract": "candidate-manifests/visual-evidence-contract.json" + }, + "grant_policy": { + "wildcard_grant_allowed": false, + "package_default_grants_all_tools": false, + "tool_grants_are_granular": true + }, + "non_authorization": { + "core_promotion": false, + "seed_installation": false, + "runtime_start": false, + "profile_exposure": false, + "provider_admission": false + } +} diff --git a/candidate-manifests/vision-tool-grants.json b/candidate-manifests/vision-tool-grants.json new file mode 100644 index 0000000..29fef7b --- /dev/null +++ b/candidate-manifests/vision-tool-grants.json @@ -0,0 +1,69 @@ +{ + "schema_version": "vision.tool_grants.v1", + "package_id": "visual-perception-package-candidate", + "authority": "child-local", + "status": "candidate_only_not_seed_installed", + "canonical_sense": "VISION", + "wildcard_grant_allowed": false, + "package_default_grants_all_tools": false, + "current_tool_candidates": [ + { + "tool_id": "vision.image_analyze", + "route": "POST /vlm/analyze", + "grant_default": false, + "requires_visual_evidence": true + }, + { + "tool_id": "vision.image_generate", + "route": "POST /flux/render", + "grant_default": false, + "requires_visual_evidence": false + }, + { + "tool_id": "vision.palette_extract", + "route": "POST /palette/extract", + "grant_default": false, + "requires_visual_evidence": true + }, + { + "tool_id": "vision.background_cutout", + "route": "POST /rembg/cutout", + "grant_default": false, + "requires_visual_evidence": true + } + ], + "planned_tool_candidates": [ + "vision.ocr_read", + "vision.screenshot_observe", + "vision.browser_observe", + "vision.document_layout_read", + "vision.chart_read", + "vision.table_read", + "vision.diagram_read", + "vision.object_detect", + "vision.visual_ground", + "vision.segment", + "vision.video_read", + "vision.image_edit" + ], + "required_disclosures": { + "producing_package_id": true, + "producing_tool_id": true, + "capability_surface": true, + "provider_mode": true, + "retention_disclosure": true, + "validation_status": true + }, + "forbidden_effects": [ + "research_synthesis", + "textual_web_search", + "textual_page_fetch", + "pdf_text_extraction", + "deep_research_workflow", + "capsule_writing", + "runtime_start", + "profile_exposure", + "provider_admission", + "wildcard_tool_exposure" + ] +} diff --git a/candidate-manifests/visual-evidence-contract.json b/candidate-manifests/visual-evidence-contract.json new file mode 100644 index 0000000..5c196d5 --- /dev/null +++ b/candidate-manifests/visual-evidence-contract.json @@ -0,0 +1,37 @@ +{ + "schema_version": "vision.visual_evidence_contract.v1", + "package_id": "visual-perception-package-candidate", + "authority": "child-local", + "status": "candidate_only_not_seed_installed", + "required_fields": [ + "producing_package_id", + "producing_tool_id", + "capability_surface", + "source_reference", + "provider_mode", + "retention_disclosure", + "observed_content_summary", + "extracted_claims", + "confidence", + "caveats", + "timestamp", + "validation_status" + ], + "first_vertical_proof": { + "source_route": "POST /vlm/analyze", + "source_tool_id": "vision.image_analyze", + "module": "src/svrnty_vision/visual_evidence.py", + "test": "tests/test_visual_evidence.py", + "live_provider_call_required": false + }, + "research_handoff": { + "research_may_consume_visual_evidence": true, + "vision_may_write_research_capsules": false, + "vision_may_perform_research_synthesis": false + }, + "disclosure_contract": { + "provider_mode_required": true, + "retention_required": true, + "validation_status_required": true + } +} diff --git a/docs/VISION-HOST-ADAPTER-CANDIDATES.md b/docs/VISION-HOST-ADAPTER-CANDIDATES.md new file mode 100644 index 0000000..fe8a3c8 --- /dev/null +++ b/docs/VISION-HOST-ADAPTER-CANDIDATES.md @@ -0,0 +1,29 @@ +# VISION Host Adapter Candidates + +Status: candidate only. These adapter manifests do not install into Seed and do +not grant host access. + +## Intent + +Claude Code, Codex CLI, and Pi-Code should expose the same VISION package +capabilities when Seed accepts the package. Each host adapter is thin: it maps +host-specific command shape to the same package tool ids and the same disclosure +contract. + +## Host Targets + +- `claude-code` +- `codex-cli` +- `pi-code` + +## Parity Rule + +Each host adapter candidate must expose the same current tool ids, the same +planned tool ids, no wildcard grant, and the same Visual Evidence disclosure +contract. + +## Candidate Manifests + +- `candidate-manifests/host-adapters/vision-claude-code-adapter.json` +- `candidate-manifests/host-adapters/vision-codex-cli-adapter.json` +- `candidate-manifests/host-adapters/vision-pi-code-adapter.json` diff --git a/docs/VISION-PACKAGE-CANDIDATE.md b/docs/VISION-PACKAGE-CANDIDATE.md new file mode 100644 index 0000000..a7930a6 --- /dev/null +++ b/docs/VISION-PACKAGE-CANDIDATE.md @@ -0,0 +1,70 @@ +# VISION Package Candidate + +Status: child-local candidate only. No Core promotion, Seed installation, +Runtime start, Profile Exposure, or provider admission is authorized. No +wildcard grant is authorized by this document. + +## Intent + +`svrnty-vision` is the generic visual-perception package candidate for the +canonical Cortex OS sense `VISION`. It owns tools that inspect or produce pixels, +images, screenshots, browser observations, layouts, charts, diagrams, grounded +regions, segmentations, video frames, or generated/edited images. + +`research` is also under the `VISION` sense family, but it owns textual/source +reading and research workflows. The boundary is by capability, not by sense name: +Research reads sources; Vision sees media. + +## Current Route Adapters + +| Current route | Candidate tool id | Capability | +| --- | --- | --- | +| `POST /vlm/analyze` | `vision.image_analyze` | Analyze image input with a VLM and return a normalized observation. | +| `POST /flux/render` | `vision.image_generate` | Generate image output through the existing FLUX route. | +| `POST /palette/extract` | `vision.palette_extract` | Extract dominant colors from image input. | +| `POST /rembg/cutout` | `vision.background_cutout` | Remove image background and return cutout output. | + +## Planned Tool Candidates + +The complete VISION visual-perception package should cover: + +- `vision.ocr_read` +- `vision.screenshot_observe` +- `vision.browser_observe` +- `vision.document_layout_read` +- `vision.chart_read` +- `vision.table_read` +- `vision.diagram_read` +- `vision.object_detect` +- `vision.visual_ground` +- `vision.segment` +- `vision.video_read` +- `vision.image_edit` + +These are not implemented or granted by this slice. They are named so future +work has a canonical target and does not duplicate Research capabilities. + +## Boundary + +Owned here: + +- Pixel/media perception. +- Visual evidence production. +- Image generation or editing. +- Visual extraction from screenshots, browser views, image files, video frames, + charts, diagrams, and layouts. + +Not owned here: + +- Web search. +- Page fetch. +- PDF text extraction. +- Research synthesis. +- Deep research planning. +- Capsule writing. +- Profile Exposure. +- Runtime startup. +- Provider admission. + +Research can consume Visual Evidence only through an explicit handoff contract. +Vision never becomes a research synthesizer by returning evidence. diff --git a/docs/VISUAL-EVIDENCE-CONTRACT.md b/docs/VISUAL-EVIDENCE-CONTRACT.md new file mode 100644 index 0000000..89bc640 --- /dev/null +++ b/docs/VISUAL-EVIDENCE-CONTRACT.md @@ -0,0 +1,42 @@ +# Visual Evidence Contract + +Status: candidate contract. This is route-only evidence for Cortex OS and Seed +review. It does not grant tools or promote the package. + +## Required Fields + +Every Visual Evidence record must include: + +- `producing_package_id` +- `producing_tool_id` +- `capability_surface` +- `source_reference` +- `provider_mode` +- `retention_disclosure` +- `observed_content_summary` +- `extracted_claims` +- `confidence` +- `caveats` +- `timestamp` +- `validation_status` + +## First Vertical Proof + +The first proof adapts a raw-mode `POST /vlm/analyze` response into Visual +Evidence through a pure Python adapter. It does not call a live provider. + +Proof module: `src/svrnty_vision/visual_evidence.py` + +Proof test: `tests/test_visual_evidence.py` + +## Research Handoff Rule + +Research may cite Visual Evidence as an input source if the record includes the +required fields and validation status. Research owns synthesis and capsule writing. +Vision owns the visual observation record only. + +## Provider And Retention Disclosure + +Provider mode and retention are mandatory because host agents must be able to +disclose how the visual observation was produced. Missing disclosure invalidates +the evidence record. diff --git a/outputs/2026-06-06-svrnty-vision-work-002-package-candidate-validation.md b/outputs/2026-06-06-svrnty-vision-work-002-package-candidate-validation.md new file mode 100644 index 0000000..b016e26 --- /dev/null +++ b/outputs/2026-06-06-svrnty-vision-work-002-package-candidate-validation.md @@ -0,0 +1,55 @@ +# SVRNTY-VISION-WORK-002 Validation Evidence + +Date: 2026-06-06 + +Scope: generic Cortex OS VISION package candidate and Visual Evidence proof for +`svrnty-vision`. + +## What Changed + +- Added child-local VISION package candidate docs and manifests. +- Mapped current HTTP routes to candidate tool ids. +- Added granular tool-grant candidate manifest with no wildcard grants. +- Added Visual Evidence contract and a pure VLM response adapter proof. +- Added host adapter candidate manifests for Claude Code, Codex CLI, and + Pi-Code. +- Added a local sandcastle handoff for the next Seed acceptance route. +- Fixed package dependency declaration from `rembg` to `rembg[cpu]` so the + background cutout route has the required CPU backend on fresh installs. + +## Gates Passed + +- `python3 tools/validate_vision_package_candidate.py` +- `python3 tools/validate_vision_tool_grants.py` +- `python3 tools/validate_visual_evidence_contract.py` +- `python3 tools/validate_vision_host_adapter_candidates.py` +- `python3 tools/validate_svrnty_vision_child.py` +- `.venv/bin/python tools/validate_svrnty_vision_child.py` +- `pytest --noconftest tests/test_visual_evidence.py` +- `.venv/bin/pytest tests/ -m 'not integration'` +- `python3 -m py_compile src/svrnty_vision/visual_evidence.py tools/validate_vision_package_candidate.py tools/validate_vision_tool_grants.py tools/validate_visual_evidence_contract.py tools/validate_vision_host_adapter_candidates.py tools/validate_svrnty_vision_child.py` +- `.venv/bin/python -m py_compile src/svrnty_vision/visual_evidence.py tools/validate_vision_package_candidate.py tools/validate_vision_tool_grants.py tools/validate_visual_evidence_contract.py tools/validate_vision_host_adapter_candidates.py tools/validate_svrnty_vision_child.py` +- `git diff --check` + +## Test Result + +Non-integration suite after `rembg[cpu]` dependency fix: + +- 30 passed. +- 10 integration tests deselected. +- 1 Starlette/httpx deprecation warning. + +## Boundaries Preserved + +- No Core mutation. +- No Seed installation. +- No Runtime start. +- No Profile Exposure. +- No provider admission. +- No wildcard grant. +- No Research synthesis in Vision. + +## Follow-Up + +Follow-up, optional: update the FastAPI/TestClient dependency path when the +Starlette/httpx deprecation becomes a compatibility issue. diff --git a/outputs/sandcastle-vision-package-candidate/README.md b/outputs/sandcastle-vision-package-candidate/README.md new file mode 100644 index 0000000..182167c --- /dev/null +++ b/outputs/sandcastle-vision-package-candidate/README.md @@ -0,0 +1,34 @@ +# Vision Package Candidate Sandcastle + +Status: ready for the next agent. This sandcastle belongs inside +`svrnty-vision`; it is not an umbrella repo and not a Core route. + +## End State + +Make `svrnty-vision` Seed-acceptable as the generic Cortex OS VISION +visual-perception package candidate, with host parity for Claude Code, Codex CLI, +and Pi-Code, granular grants, and Visual Evidence disclosure. + +## Current Vertical Slice + +- Package boundary documented. +- Current BTE routes mapped to Cortex OS candidate tool ids. +- Planned SOTA visual tool inventory named without implementing all tools. +- Visual Evidence contract documented. +- VLM raw-mode output adapted into Visual Evidence through a pure proof. +- Host adapter candidate parity documented. + +## Stop Conditions + +- Do not mutate Core from this workspace. +- Do not install into Seed from this workspace. +- Do not start Runtime. +- Do not grant Profile Exposure. +- Do not admit providers. +- Do not wildcard expose all tools. + +## Next ROI + +Build the first Seed route outside this workspace that can accept these manifests +as candidate artifacts, then prove one host adapter can expose exactly one tool +grant without wildcard access. diff --git a/pyproject.toml b/pyproject.toml index a2dbd86..2bc7ded 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ "httpx>=0.27,<1.0", "Pillow>=11,<13", "colorthief>=0.2.1", - "rembg>=2.0,<3.0", + "rembg[cpu]>=2.0,<3.0", ] [project.optional-dependencies] diff --git a/requirements.txt b/requirements.txt index d373bdc..2aacf4a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ pydantic-settings>=2.6,<3.0 httpx>=0.27,<1.0 Pillow>=11,<13 colorthief>=0.2.1 -rembg>=2.0,<3.0 +rembg[cpu]>=2.0,<3.0 # Test deps pytest>=8.3,<9.0 diff --git a/src/svrnty_vision/visual_evidence.py b/src/svrnty_vision/visual_evidence.py new file mode 100644 index 0000000..f5fd117 --- /dev/null +++ b/src/svrnty_vision/visual_evidence.py @@ -0,0 +1,148 @@ +"""Visual Evidence adapter for Cortex OS VISION package candidates.""" + +from __future__ import annotations + +import json +from typing import Any, Protocol + +from pydantic import BaseModel, Field + +PACKAGE_ID = "visual-perception-package-candidate" +DEFAULT_VALIDATION_STATUS = "candidate_validated" + + +class VlmAnalysisResponse(Protocol): + justification: str + model_id: str + raw_scores_json: str + + +class VisualEvidence(BaseModel): + """Normalized visual observation record for host disclosure and handoff.""" + + producing_package_id: str = PACKAGE_ID + producing_tool_id: str + capability_surface: str + source_reference: str + provider_mode: str + retention_disclosure: str + observed_content_summary: str + extracted_claims: list[str] = Field(default_factory=list) + confidence: str + caveats: list[str] = Field(default_factory=list) + timestamp: str + validation_status: str + model_id: str | None = None + raw_observation_ref: str | None = None + + +def visual_evidence_from_vlm_response( + response: VlmAnalysisResponse, + *, + source_reference: str, + provider_mode: str, + retention_disclosure: str, + timestamp: str, + producing_tool_id: str = "vision.image_analyze", + capability_surface: str = "vision.image_analyze", + confidence: str = "medium", + caveats: list[str] | None = None, + validation_status: str = DEFAULT_VALIDATION_STATUS, +) -> VisualEvidence: + """Adapt the current VLM response into a Cortex OS Visual Evidence record.""" + + _require_non_empty("source_reference", source_reference) + _require_non_empty("provider_mode", provider_mode) + _require_non_empty("retention_disclosure", retention_disclosure) + _require_non_empty("timestamp", timestamp) + + raw_text = response.raw_scores_json.strip() + parsed = _parse_json_object(raw_text) + summary = _summary_from_response(parsed, response.justification, raw_text) + claims = _claims_from_raw(parsed, raw_text) + if not claims and summary: + claims = [f"summary: {summary}"] + + return VisualEvidence( + producing_tool_id=producing_tool_id, + capability_surface=capability_surface, + source_reference=source_reference, + provider_mode=provider_mode, + retention_disclosure=retention_disclosure, + observed_content_summary=summary, + extracted_claims=claims, + confidence=confidence, + caveats=caveats or [], + timestamp=timestamp, + validation_status=validation_status, + model_id=response.model_id, + raw_observation_ref="AnalyzeResponse.raw_scores_json", + ) + + +def _require_non_empty(field_name: str, value: str) -> None: + if not value or not value.strip(): + raise ValueError(f"{field_name} is required for Visual Evidence") + + +def _parse_json_object(raw_text: str) -> dict[str, Any] | None: + if not raw_text: + return None + try: + parsed = json.loads(raw_text) + except json.JSONDecodeError: + return None + return parsed if isinstance(parsed, dict) else None + + +def _summary_from_response( + parsed: dict[str, Any] | None, justification: str, raw_text: str +) -> str: + if parsed: + for key in ( + "observed_content_summary", + "summary", + "description", + "caption", + "text", + ): + value = parsed.get(key) + if isinstance(value, str) and value.strip(): + return _compact(value) + if justification.strip(): + return _compact(justification) + return _compact(raw_text) + + +def _claims_from_raw(parsed: dict[str, Any] | None, raw_text: str) -> list[str]: + if not parsed: + return [f"raw: {_compact(raw_text)}"] if raw_text else [] + + claims: list[str] = [] + for key, value in parsed.items(): + claims.append(f"{key}: {_format_claim_value(value)}") + return claims + + +def _format_claim_value(value: Any) -> str: + if isinstance(value, list): + if not value: + return "[]" + if all( + isinstance(item, str | int | float | bool) or item is None + for item in value + ): + return ", ".join(str(item) for item in value) + return json.dumps(value, sort_keys=True, separators=(",", ":")) + if isinstance(value, dict): + return json.dumps(value, sort_keys=True, separators=(",", ":")) + if value is None: + return "null" + return str(value) + + +def _compact(text: str, limit: int = 500) -> str: + compacted = " ".join(text.split()) + if len(compacted) <= limit: + return compacted + return compacted[: limit - 3].rstrip() + "..." diff --git a/tests/test_visual_evidence.py b/tests/test_visual_evidence.py new file mode 100644 index 0000000..83b3a57 --- /dev/null +++ b/tests/test_visual_evidence.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from svrnty_vision.visual_evidence import visual_evidence_from_vlm_response + + +def test_vlm_raw_mode_response_becomes_visual_evidence() -> None: + response = SimpleNamespace( + rubric_mode="raw", + justification="", + model_id="qwen-test", + raw_scores_json='{"description":"solid red square","objects":["red square"],"detected_text":[]}', + ) + + evidence = visual_evidence_from_vlm_response( + response, + source_reference="fixture://red-square.png", + provider_mode="sovereign", + retention_disclosure="synchronous_no_async_persistence", + timestamp="2026-06-06T00:00:00Z", + ) + + assert evidence.producing_package_id == "visual-perception-package-candidate" + assert evidence.producing_tool_id == "vision.image_analyze" + assert evidence.capability_surface == "vision.image_analyze" + assert evidence.source_reference == "fixture://red-square.png" + assert evidence.provider_mode == "sovereign" + assert evidence.retention_disclosure == "synchronous_no_async_persistence" + assert evidence.observed_content_summary == "solid red square" + assert "description: solid red square" in evidence.extracted_claims + assert "objects: red square" in evidence.extracted_claims + assert "detected_text: []" in evidence.extracted_claims + assert evidence.validation_status == "candidate_validated" + assert "research_synthesis" not in evidence.model_dump() + + +def test_non_json_vlm_response_gets_safe_raw_fallback() -> None: + response = SimpleNamespace( + rubric_mode="raw", + justification="", + model_id="qwen-test", + raw_scores_json="a screenshot of a pricing page with two columns", + ) + + evidence = visual_evidence_from_vlm_response( + response, + source_reference="fixture://pricing.png", + provider_mode="sovereign", + retention_disclosure="synchronous_no_async_persistence", + timestamp="2026-06-06T00:00:00Z", + ) + + assert evidence.observed_content_summary == "a screenshot of a pricing page with two columns" + assert evidence.extracted_claims == [ + "raw: a screenshot of a pricing page with two columns" + ] diff --git a/tools/validate_svrnty_vision_child.py b/tools/validate_svrnty_vision_child.py index 4804e54..9702d74 100755 --- a/tools/validate_svrnty_vision_child.py +++ b/tools/validate_svrnty_vision_child.py @@ -3,20 +3,42 @@ from __future__ import annotations import json +import subprocess +import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -REQUIRED = ["AGENTS.md", "README.md", "WORKBOARD.yaml"] +REQUIRED = [ + "AGENTS.md", + "README.md", + "WORKBOARD.yaml", + "CONTEXT.md", + "docs/VISION-PACKAGE-CANDIDATE.md", + "docs/VISUAL-EVIDENCE-CONTRACT.md", + "docs/VISION-HOST-ADAPTER-CANDIDATES.md", + "candidate-manifests/vision-package-candidate.json", + "candidate-manifests/vision-tool-grants.json", + "candidate-manifests/visual-evidence-contract.json", +] +VALIDATORS = [ + "tools/validate_vision_package_candidate.py", + "tools/validate_vision_tool_grants.py", + "tools/validate_visual_evidence_contract.py", + "tools/validate_vision_host_adapter_candidates.py", +] def main() -> int: errors: list[str] = [] + validator_outputs: dict[str, str] = {} for rel in REQUIRED: if not (ROOT / rel).exists(): errors.append(f"missing:{rel}") checks = { "AGENTS.md": ["child-local", "not Cortex OS Core authority", "python3 tools/validate_svrnty_vision_child.py"], "WORKBOARD.yaml": ["SVRNTY-VISION-WORK-001", "status: candidate", "owner: jp"], + "CONTEXT.md": ["Visual Perception Package Candidate", "Research Handoff"], + "docs/VISION-PACKAGE-CANDIDATE.md": ["Research reads sources; Vision sees media", "wildcard grant"], } for rel, snippets in checks.items(): path = ROOT / rel @@ -25,7 +47,29 @@ def main() -> int: for snippet in snippets: if snippet not in text: errors.append(f"{rel}:missing:{snippet}") - result = {"ok": not errors, "validator": "svrnty-vision-child-v1", "checked": REQUIRED, "errors": errors, "warnings": []} + for rel in VALIDATORS: + path = ROOT / rel + if not path.exists(): + errors.append(f"missing:{rel}") + continue + completed = subprocess.run( + [sys.executable, str(path)], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + validator_outputs[rel] = completed.stdout.strip() + if completed.returncode != 0: + errors.append(f"validator_failed:{rel}:{completed.stderr.strip()}") + result = { + "ok": not errors, + "validator": "svrnty-vision-child-v1", + "checked": REQUIRED + VALIDATORS, + "errors": errors, + "warnings": [], + "validator_outputs": validator_outputs, + } print(json.dumps(result, indent=2, sort_keys=True)) return 0 if result["ok"] else 1 diff --git a/tools/validate_vision_host_adapter_candidates.py b/tools/validate_vision_host_adapter_candidates.py new file mode 100644 index 0000000..ae47545 --- /dev/null +++ b/tools/validate_vision_host_adapter_candidates.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Validate VISION host adapter candidate parity.""" + +from __future__ import annotations + +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +HOSTS = { + "claude-code": "candidate-manifests/host-adapters/vision-claude-code-adapter.json", + "codex-cli": "candidate-manifests/host-adapters/vision-codex-cli-adapter.json", + "pi-code": "candidate-manifests/host-adapters/vision-pi-code-adapter.json", +} + +CURRENT_TOOLS = [ + "vision.image_analyze", + "vision.image_generate", + "vision.palette_extract", + "vision.background_cutout", +] + +PLANNED_TOOLS = [ + "vision.ocr_read", + "vision.screenshot_observe", + "vision.browser_observe", + "vision.document_layout_read", + "vision.chart_read", + "vision.table_read", + "vision.diagram_read", + "vision.object_detect", + "vision.visual_ground", + "vision.segment", + "vision.video_read", + "vision.image_edit", +] + + +def main() -> int: + errors: list[str] = [] + checked = list(HOSTS.values()) + ["docs/VISION-HOST-ADAPTER-CANDIDATES.md"] + manifests: dict[str, dict] = {} + for host, rel in HOSTS.items(): + manifest = _read_json(ROOT / rel, errors) + if manifest: + manifests[host] = manifest + + for host, manifest in manifests.items(): + _expect(manifest.get("host_runtime") == host, f"host_runtime_mismatch:{host}", errors) + _expect(manifest.get("package_id") == "visual-perception-package-candidate", f"package_mismatch:{host}", errors) + _expect(manifest.get("adapter_role") == "thin_access_adapter", f"adapter_role_mismatch:{host}", errors) + _expect(manifest.get("wildcard_grant_allowed") is False, f"wildcard_grant_allowed:{host}", errors) + _expect(manifest.get("current_tool_candidates") == CURRENT_TOOLS, f"current_tools_mismatch:{host}", errors) + _expect(manifest.get("planned_tool_candidates") == PLANNED_TOOLS, f"planned_tools_mismatch:{host}", errors) + disclosures = manifest.get("disclosure_contract", {}) + for key in ("provider_mode_required", "retention_required", "visual_evidence_required"): + if disclosures.get(key) is not True: + errors.append(f"disclosure_missing:{host}:{key}") + forbidden = set(manifest.get("forbidden_effects", [])) + for effect in ("research_synthesis", "textual_web_search", "runtime_start", "wildcard_tool_exposure"): + if effect not in forbidden: + errors.append(f"forbidden_effect_missing:{host}:{effect}") + for key, value in manifest.get("non_authorization", {}).items(): + if value is not False: + errors.append(f"non_authorization_enabled:{host}:{key}") + + if len(manifests) == len(HOSTS): + current_sets = {tuple(m["current_tool_candidates"]) for m in manifests.values()} + planned_sets = {tuple(m["planned_tool_candidates"]) for m in manifests.values()} + _expect(len(current_sets) == 1, "current_tool_parity_failed", errors) + _expect(len(planned_sets) == 1, "planned_tool_parity_failed", errors) + + _check_snippets( + "docs/VISION-HOST-ADAPTER-CANDIDATES.md", + ["Claude Code", "Codex CLI", "Pi-Code", "no wildcard grant"], + errors, + ) + + result = { + "ok": not errors, + "validator": "vision-host-adapter-candidates-v1", + "checked": checked, + "errors": errors, + "warnings": [], + } + print(json.dumps(result, indent=2, sort_keys=True)) + return 0 if result["ok"] else 1 + + +def _read_json(path: Path, errors: list[str]) -> dict | None: + if not path.exists(): + errors.append(f"missing:{path.relative_to(ROOT)}") + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + errors.append(f"{path.relative_to(ROOT)}:json:{exc}") + return None + + +def _check_snippets(rel: str, snippets: list[str], errors: list[str]) -> None: + path = ROOT / rel + if not path.exists(): + errors.append(f"missing:{rel}") + return + text = path.read_text(encoding="utf-8") + for snippet in snippets: + if snippet not in text: + errors.append(f"{rel}:missing:{snippet}") + + +def _expect(condition: bool, error: str, errors: list[str]) -> None: + if not condition: + errors.append(error) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/validate_vision_package_candidate.py b/tools/validate_vision_package_candidate.py new file mode 100644 index 0000000..e57c0c3 --- /dev/null +++ b/tools/validate_vision_package_candidate.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Validate the child-local VISION package candidate manifest and docs.""" + +from __future__ import annotations + +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +CURRENT_TOOLS = [ + "vision.image_analyze", + "vision.image_generate", + "vision.palette_extract", + "vision.background_cutout", +] + +PLANNED_TOOLS = [ + "vision.ocr_read", + "vision.screenshot_observe", + "vision.browser_observe", + "vision.document_layout_read", + "vision.chart_read", + "vision.table_read", + "vision.diagram_read", + "vision.object_detect", + "vision.visual_ground", + "vision.segment", + "vision.video_read", + "vision.image_edit", +] + +ROUTE_TOOLS = { + "POST /vlm/analyze": "vision.image_analyze", + "POST /flux/render": "vision.image_generate", + "POST /palette/extract": "vision.palette_extract", + "POST /rembg/cutout": "vision.background_cutout", +} + + +def main() -> int: + errors: list[str] = [] + required = [ + "CONTEXT.md", + "docs/VISION-PACKAGE-CANDIDATE.md", + "candidate-manifests/vision-package-candidate.json", + ] + for rel in required: + if not (ROOT / rel).exists(): + errors.append(f"missing:{rel}") + + manifest_path = ROOT / "candidate-manifests/vision-package-candidate.json" + manifest = _read_json(manifest_path, errors) + if manifest: + _expect(manifest.get("canonical_sense") == "VISION", "canonical_sense_not_vision", errors) + _expect( + manifest.get("status") == "candidate_only_not_seed_installed", + "status_not_candidate_only", + errors, + ) + _expect(manifest.get("current_tool_candidates") == CURRENT_TOOLS, "current_tools_mismatch", errors) + _expect(manifest.get("planned_tool_candidates") == PLANNED_TOOLS, "planned_tools_mismatch", errors) + route_map = { + item.get("route"): item.get("tool_id") + for item in manifest.get("current_route_adapters", []) + } + _expect(route_map == ROUTE_TOOLS, "route_tool_map_mismatch", errors) + grant_policy = manifest.get("grant_policy", {}) + _expect(grant_policy.get("wildcard_grant_allowed") is False, "wildcard_grant_allowed", errors) + _expect( + grant_policy.get("package_default_grants_all_tools") is False, + "package_default_grants_all_tools", + errors, + ) + _expect(grant_policy.get("tool_grants_are_granular") is True, "tool_grants_not_granular", errors) + for key, value in manifest.get("non_authorization", {}).items(): + if value is not False: + errors.append(f"non_authorization_enabled:{key}") + forbidden = set(manifest.get("forbidden_research_owned_capabilities", [])) + for capability in ("web_search", "fetch_page", "extract_pdf", "deep_research", "research_synthesis"): + if capability not in forbidden: + errors.append(f"missing_forbidden_research_capability:{capability}") + + _check_snippets( + "docs/VISION-PACKAGE-CANDIDATE.md", + [ + "Research reads sources; Vision sees media", + "No Core promotion", + "wildcard grant", + "`vision.image_analyze`", + "`vision.video_read`", + ], + errors, + ) + _check_snippets( + "CONTEXT.md", + ["Visual Evidence", "Research Handoff", "BTE Compatibility Surface"], + errors, + ) + + result = { + "ok": not errors, + "validator": "vision-package-candidate-v1", + "checked": required, + "errors": errors, + "warnings": [], + } + print(json.dumps(result, indent=2, sort_keys=True)) + return 0 if result["ok"] else 1 + + +def _read_json(path: Path, errors: list[str]) -> dict | None: + if not path.exists(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + errors.append(f"{path.relative_to(ROOT)}:json:{exc}") + return None + + +def _check_snippets(rel: str, snippets: list[str], errors: list[str]) -> None: + path = ROOT / rel + if not path.exists(): + return + text = path.read_text(encoding="utf-8") + for snippet in snippets: + if snippet not in text: + errors.append(f"{rel}:missing:{snippet}") + + +def _expect(condition: bool, error: str, errors: list[str]) -> None: + if not condition: + errors.append(error) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/validate_vision_tool_grants.py b/tools/validate_vision_tool_grants.py new file mode 100644 index 0000000..1735884 --- /dev/null +++ b/tools/validate_vision_tool_grants.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Validate granular VISION tool grant candidate manifest.""" + +from __future__ import annotations + +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +CURRENT_TOOLS = [ + "vision.image_analyze", + "vision.image_generate", + "vision.palette_extract", + "vision.background_cutout", +] + +PLANNED_TOOLS = [ + "vision.ocr_read", + "vision.screenshot_observe", + "vision.browser_observe", + "vision.document_layout_read", + "vision.chart_read", + "vision.table_read", + "vision.diagram_read", + "vision.object_detect", + "vision.visual_ground", + "vision.segment", + "vision.video_read", + "vision.image_edit", +] + + +def main() -> int: + errors: list[str] = [] + path = ROOT / "candidate-manifests/vision-tool-grants.json" + manifest = _read_json(path, errors) + if manifest: + _expect(manifest.get("canonical_sense") == "VISION", "canonical_sense_not_vision", errors) + _expect(manifest.get("wildcard_grant_allowed") is False, "wildcard_grant_allowed", errors) + _expect( + manifest.get("package_default_grants_all_tools") is False, + "package_default_grants_all_tools", + errors, + ) + current = manifest.get("current_tool_candidates", []) + current_tool_ids = [item.get("tool_id") for item in current] + _expect(current_tool_ids == CURRENT_TOOLS, "current_tool_ids_mismatch", errors) + _expect(manifest.get("planned_tool_candidates") == PLANNED_TOOLS, "planned_tools_mismatch", errors) + for item in current: + if item.get("grant_default") is not False: + errors.append(f"grant_default_enabled:{item.get('tool_id')}") + disclosures = manifest.get("required_disclosures", {}) + for field in ( + "producing_package_id", + "producing_tool_id", + "capability_surface", + "provider_mode", + "retention_disclosure", + "validation_status", + ): + if disclosures.get(field) is not True: + errors.append(f"required_disclosure_missing:{field}") + forbidden = set(manifest.get("forbidden_effects", [])) + for effect in ( + "research_synthesis", + "textual_web_search", + "textual_page_fetch", + "deep_research_workflow", + "wildcard_tool_exposure", + ): + if effect not in forbidden: + errors.append(f"forbidden_effect_missing:{effect}") + + result = { + "ok": not errors, + "validator": "vision-tool-grants-v1", + "checked": ["candidate-manifests/vision-tool-grants.json"], + "errors": errors, + "warnings": [], + } + print(json.dumps(result, indent=2, sort_keys=True)) + return 0 if result["ok"] else 1 + + +def _read_json(path: Path, errors: list[str]) -> dict | None: + if not path.exists(): + errors.append(f"missing:{path.relative_to(ROOT)}") + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + errors.append(f"{path.relative_to(ROOT)}:json:{exc}") + return None + + +def _expect(condition: bool, error: str, errors: list[str]) -> None: + if not condition: + errors.append(error) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/validate_visual_evidence_contract.py b/tools/validate_visual_evidence_contract.py new file mode 100644 index 0000000..d1dd5d7 --- /dev/null +++ b/tools/validate_visual_evidence_contract.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Validate the Visual Evidence contract and pure adapter proof.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +REQUIRED_FIELDS = [ + "producing_package_id", + "producing_tool_id", + "capability_surface", + "source_reference", + "provider_mode", + "retention_disclosure", + "observed_content_summary", + "extracted_claims", + "confidence", + "caveats", + "timestamp", + "validation_status", +] + + +def main() -> int: + errors: list[str] = [] + required = [ + "docs/VISUAL-EVIDENCE-CONTRACT.md", + "candidate-manifests/visual-evidence-contract.json", + "src/svrnty_vision/visual_evidence.py", + "tests/test_visual_evidence.py", + ] + for rel in required: + if not (ROOT / rel).exists(): + errors.append(f"missing:{rel}") + + manifest = _read_json(ROOT / "candidate-manifests/visual-evidence-contract.json", errors) + if manifest: + _expect(manifest.get("required_fields") == REQUIRED_FIELDS, "required_fields_mismatch", errors) + proof = manifest.get("first_vertical_proof", {}) + _expect(proof.get("source_tool_id") == "vision.image_analyze", "proof_tool_mismatch", errors) + _expect(proof.get("live_provider_call_required") is False, "live_provider_required", errors) + handoff = manifest.get("research_handoff", {}) + _expect(handoff.get("research_may_consume_visual_evidence") is True, "research_handoff_missing", errors) + _expect(handoff.get("vision_may_write_research_capsules") is False, "vision_writes_capsules", errors) + _expect(handoff.get("vision_may_perform_research_synthesis") is False, "vision_research_synthesis", errors) + + _check_snippets( + "docs/VISUAL-EVIDENCE-CONTRACT.md", + [ + "`producing_package_id`", + "Research owns synthesis and capsule writing", + "does not call a live provider", + ], + errors, + ) + _validate_adapter(errors) + + result = { + "ok": not errors, + "validator": "visual-evidence-contract-v1", + "checked": required, + "errors": errors, + "warnings": [], + } + print(json.dumps(result, indent=2, sort_keys=True)) + return 0 if result["ok"] else 1 + + +def _validate_adapter(errors: list[str]) -> None: + try: + from types import SimpleNamespace + + from svrnty_vision.visual_evidence import visual_evidence_from_vlm_response + except Exception as exc: # pragma: no cover - validator import report + errors.append(f"adapter_import_failed:{type(exc).__name__}:{exc}") + return + + response = SimpleNamespace( + rubric_mode="raw", + justification="", + model_id="qwen-test", + raw_scores_json='{"description":"solid red square","objects":["red square"],"detected_text":[]}', + ) + evidence = visual_evidence_from_vlm_response( + response, + source_reference="fixture://red-square.png", + provider_mode="sovereign", + retention_disclosure="synchronous_no_async_persistence", + timestamp="2026-06-06T00:00:00Z", + ) + dumped = evidence.model_dump() + for field in REQUIRED_FIELDS: + if field not in dumped: + errors.append(f"adapter_missing_field:{field}") + _expect(dumped.get("producing_package_id") == "visual-perception-package-candidate", "adapter_package_mismatch", errors) + _expect(dumped.get("producing_tool_id") == "vision.image_analyze", "adapter_tool_mismatch", errors) + _expect(dumped.get("observed_content_summary") == "solid red square", "adapter_summary_mismatch", errors) + _expect("description: solid red square" in dumped.get("extracted_claims", []), "adapter_claim_missing", errors) + + +def _read_json(path: Path, errors: list[str]) -> dict | None: + if not path.exists(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + errors.append(f"{path.relative_to(ROOT)}:json:{exc}") + return None + + +def _check_snippets(rel: str, snippets: list[str], errors: list[str]) -> None: + path = ROOT / rel + if not path.exists(): + return + text = path.read_text(encoding="utf-8") + for snippet in snippets: + if snippet not in text: + errors.append(f"{rel}:missing:{snippet}") + + +def _expect(condition: bool, error: str, errors: list[str]) -> None: + if not condition: + errors.append(error) + + +if __name__ == "__main__": + raise SystemExit(main())