diff --git a/README.md b/README.md index 7e445e764..11d268a45 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ A convincing 3D view can still miss an obstacle or get a distance wrong, so ever | [docs/03-stack-research.md](docs/03-stack-research.md) | The open-source projects and libraries we build on, and the ones we skip | | [docs/04-prior-art-and-codes.md](docs/04-prior-art-and-codes.md) | Base's public rules, similar products, and electrical-code citations | | [docs/05-live-guided-survey-hld.md](docs/05-live-guided-survey-hld.md) | Proposed live guidance and automatic capture architecture, diagrams, evidence flow, and technical resources | +| [docs/06-research-handoff.md](docs/06-research-handoff.md) | Research findings, recommended architecture, tested prototypes, alternatives, and remaining field-validation work | | [docs/eli5.html](docs/eli5.html) | A visual walkthrough with an interactive demo of the placement search | Contributors and coding agents start with [AGENTS.md](AGENTS.md). This repository is public, so materials Base gave the team stay in the git-ignored `private/` folder. diff --git a/docs/06-research-handoff.md b/docs/06-research-handoff.md new file mode 100644 index 000000000..fd2f64c54 --- /dev/null +++ b/docs/06-research-handoff.md @@ -0,0 +1,83 @@ +# Live survey research: findings and handoff + +September 26, 2026. Research stopped at the user's request. This report packages the completed work, useful implementation proposals, and remaining validation gaps. It is a research handoff, not a production-readiness or installation-accuracy claim. + +**Recommendation: build a hybrid evidence loop around native capture.** Use Swift, ARKit and RealityKit for the open camera, pose tracking, photo selection and immediate guidance. Save each useful original photograph with its own calibration and pose. Let asynchronous services perform heavier recognition and reconstruction, then evaluate requirements with explicit evidence and uncertainty. A complete photorealistic house model should not block a useful, source-backed answer. + +## What was achieved + +| Area | Completed work | Practical outcome | +| --- | --- | --- | +| Camera and capture | Compared native iOS, Android, browser and native-module paths; typechecked relevant ARKit still-capture and guidance APIs; built a durable capture-admission prototype | Native iOS is the leading integration path. Progress must follow the returned, accepted, durably saved photograph—not the preview that triggered it | +| Coverage and guidance | Explored surface coverage, unknown space, task evidence, next-view policies and screen projection; exercised synthetic policies and actual native coverage code | Separate **where to move**, **where to aim**, and **what evidence is still missing**. Geometry revisions can invalidate earlier coverage | +| Reconstruction and visualization | Audited classical, learned batch, streaming and phone-depth alternatives; ran bounded DA3, MoGe, Apple depth and COLMAP probes | Several components run locally, but none establishes exterior measurement accuracy. Keep fast local geometry and slower reconstruction revisions separate | +| Recognition and association | Tested OCR/barcodes, box trackers, image correspondence and causal point tracking; built a revision-aware association prototype | Recognition confidence and a persistent tracking ID do not prove the correct equipment or exact label value | +| Architecture and integration | Checked camera/calibration conversions, evidence admission, reset races, export/version semantics and deployment limits | Preserve originals and coordinate epochs; use resumable object storage separately from compact placement requests | +| Reviewable implementation | Published two standalone Python prototypes and two isolated Swift patch proposals; existing camera-conversion work remains in [PR #9](https://github.com/SamGu-NRX/house-scanning/pull/9) | Useful work is available for selective integration without changing teammates' application branches | + +The detailed [options and resources](research/2026-09-26-options-and-resources.md) retain alternatives. The [experiment evidence](research/2026-09-26-experiment-evidence.md) records positive, negative, blocked and unrun outcomes. + +## Recommended flow + +```mermaid +flowchart TD + A[Open camera and capability checks] --> B[Local tracking and image-quality checks] + B --> C[Coverage and missing task evidence] + C --> D[Move cue or aim cue] + D --> E[Select a useful still] + E --> F[Assess actual returned image and metadata] + F --> G[Durably admit original and evidence] + G --> C + G --> H[Resumable upload to object storage] + H --> I[Asynchronous recognition and reconstruction] + I --> J[Versioned geometry and sourced observations] + J --> K[Deterministic checks with uncertainty] + K --> L[Supported result or specific missing evidence] + L --> C + J --> M[Inspectable 3D preview with original photos] +``` + +Local feedback continues while uploads and workers are pending. Capture completion, upload completion, analysis completion and assessment completion are separate states. A newer result is accepted only when its evidence and coordinate dependencies are still valid. + +## Findings that change the design + +1. **A view is useful only after the actual still is assessed and saved.** A preview may look sharp while the returned still is late, blurred, from another pose, or associated with an obsolete task. The capture prototype makes admission and progress transactional, preserves original evidence, and prevents old coordinate epochs from silently satisfying current spatial requirements. + +2. **Fog of war needs more than the path the customer walked.** Surface coverage, unknown/occupied space and required close-up evidence need separate records. A missed ray in an incomplete mesh means unknown. It does not establish open space or a safe route. If ground or wall geometry changes, dependent coverage must be recomputed or invalidated. + +3. **Coverage can become stale in the current native implementation.** At the inspected source commit, changing only the ground height retained 14 covered cells; a fresh map with the same cameras and new height had zero covered cells. This is a reproducible software-state inconsistency, not a sensor-accuracy result. The published replay helper is a limited, unrun proposal; its refusal of an unresolved horizontal cell shift still needs an integration policy. + +4. **Reconstruction updates can revise earlier geometry.** Adding a fourth image to the DA3 batch changed earlier depth estimates: 3.06% mean absolute relative revision before alignment and 6.51% after it. These describe change, not improved accuracy. Measurements and results derived from revised geometry need revalidation. + +5. **Tracking needs a recovery path and independent context checks.** BootsTAPIR processed all 175 frames of one public indoor book sequence. Only **457/1,392** point-frame opportunities were both marked visible and inside the object mask, despite **457/458** containment among visible outputs. There were 77 frames with no visible query. Mask containment cannot establish exact point correspondence or equipment identity. This Mac run also does not establish real-time phone performance. + +6. **OCR confidence is insufficient for exact identifiers.** Synthetic native and portable OCR checks produced identifier mistakes at high confidence. Preserve original crops, text spans and field associations; keep unreadable or conflicting values unresolved. Two inspected real photographs supplied observations, not an independently adjudicated accuracy benchmark. + +7. **Use separate transport for original photographs.** Vercel documents a 4.5 MB Function request/response envelope. Send original media to a suitable storage endpoint and submit compact scene/placement data separately. Live checks covered only health and schema reads; no upload recovery or placement service was validated. [Vercel limit](https://vercel.com/docs/functions/limitations#request-body-size) + +## Implementation delivered + +| Artifact | What it contains | Validation boundary | +| --- | --- | --- | +| [Capture admission](../experiments/research-handoff/capture-admission/README.md) | SQLite prototype for request/returned-still separation, atomic evidence admission, epoch/revision checks, crash rollback and acknowledgments | Synthetic bytes and supplied assessments; no camera, real upload or production storage integration | +| [Equipment association](../experiments/research-handoff/equipment-association/README.md) | State machine separating observations, nominated equipment, context revisions and reviewed label associations | Synthetic in-memory transitions; no physical identity or image-recognition validation | +| [Native patch proposals](../experiments/research-handoff/native-proposals/README.md) | Small current-source reset patch plus a separate ground-coverage replay helper proposal | Reset patch passed nine paired harness cases; ground helper remains unrun. Neither was applied to the app | +| [Camera metadata adapter, PR #9](https://github.com/SamGu-NRX/house-scanning/pull/9) | Camera coordinate conversion and input-validation experiment | Existing mathematical tests; not physical calibration or field accuracy | + +The public package contains original reusable code, synthetic tests, patch proposals and curated results. Downloaded datasets, model weights, build caches, private source material and detailed local run receipts remain outside Git. Historical failures are retained locally rather than silently replaced by later passes. + +Publication validation: the exported capture-admission suite passed **26 tests** and equipment-association suite passed **11 tests** on Python 3.13.7. Their READMEs contain the exact commands. Python syntax, JSON, relative document links and patch hashes were checked; the published implementations match the reviewed source. The app/server/web files were not changed or built in this handoff. + +## What remains unresolved + +**The largest gap is a real-phone session on representative exterior geometry with independently measured dimensions.** Desktop API checks and synthetic controls cannot answer whether still capture disrupts tracking, whether arrows remain aligned, or whether a phone can sustain the workload without thermal or battery problems. + +The next useful work, if resumed, is: + +1. **One small field capture:** ordinary supported iPhone first, optional LiDAR comparison. Keep per-image pose, calibration, original bytes and coordinate epochs. Record interruptions, bad captures and recovery—not just the happy path. +2. **Independent geometry checks:** measure identified endpoints with a separate method. Reserve held-out distances when fitting scale or alignment. The acquired TUM reference sequence is ready for a fixed depth-consistency diagnostic, but that diagnostic and model evaluation were not run. +3. **Focused guidance validation:** test move-versus-aim behavior, viewport/orientation changes, stale results and geometry revisions. Observe whether customers follow the cues and whether missing evidence remains visible. +4. **Durable end-to-end integration:** review the isolated reset patch, implement qualified observation replay, test interrupted/background uploads, and bind each assessment to exact image, scene and rules versions. +5. **Representative recognition and identity evaluation:** use adjudicated labels and per-point correspondence/visibility truth. Compare abstention and wrong associations as well as successful reads. + +Keep browser coaching, ARCore, alternate reconstruction models and simpler evidence-first outputs available when device reach, measured quality or deployment costs change the tradeoff. No permanent model winner or whole-house accuracy claim was established. diff --git a/docs/research/2026-09-26-evidence-index.json b/docs/research/2026-09-26-evidence-index.json new file mode 100644 index 000000000..89f4a2f1e --- /dev/null +++ b/docs/research/2026-09-26-evidence-index.json @@ -0,0 +1,97 @@ +{ + "schemaVersion": 1, + "date": "2026-09-26", + "purpose": "Selected local evidence identities for the public research handoff. Paths are relative to the retained, ignored private/research archive; these files are not shipped in this commit. Hashes do not make unshipped evidence independently reproducible. Read the accompanying experiment-evidence report for the status and scope of each result; a preparation manifest does not imply execution.", + "records": [ + { + "localArchiveRelativePath": "experiments/capture-admission/REPORT.md", + "bytes": 9448, + "sha256": "548df37bfd2d537f96cb170805c97822e0c715f678e9ade08cdf469fdc7dfbf2" + }, + { + "localArchiveRelativePath": "experiments/capture-admission/review/fix-receipts.json", + "bytes": 11513, + "sha256": "702ccf8d1ab1ea22d3f847f69e1b3ce0d5986685d750405c461aec3883f5aee5" + }, + { + "localArchiveRelativePath": "experiments/equipment-association/fix-v2/REPORT.md", + "bytes": 2151, + "sha256": "bb033b9af77b95854a3f81d227911242574c246e6694d835f0eb40486ad69588" + }, + { + "localArchiveRelativePath": "native-producer-update-20260926-1217/reset-review/current-patch/REPORT.md", + "bytes": 5509, + "sha256": "03606b7c18994aa4a4dfb84e24078cffab8dbf2433d58bdc6e950d547047709d" + }, + { + "localArchiveRelativePath": "native-producer-update-20260926-1217/reset-review/current-patch/final-manifest.json", + "bytes": 18424, + "sha256": "2a41a977e0a209d54963e263b59a8e77ff57663f41821d68d3610e05acceee56" + }, + { + "localArchiveRelativePath": "native-producer-update-20260926-1217/geometry-review/ground-update-probe/REPORT.md", + "bytes": 4505, + "sha256": "678428ffb39c1cb24c9b0a77426335a4cf3424faa780cde7e78098756ff3ab29" + }, + { + "localArchiveRelativePath": "native-producer-update-20260926-1217/geometry-review/ground-update-probe/artifact-manifest.json", + "bytes": 15089, + "sha256": "329968483d233783d6df0334f6c890b37b8fd0e7bc41b5a8bcf4b7cdae38f658" + }, + { + "localArchiveRelativePath": "experiments/bootstapir-book/REPORT.md", + "bytes": 5189, + "sha256": "1224f49aa3c3a936366c5f3393a40107a5c712bc4ce626ff73b54999315d8dde" + }, + { + "localArchiveRelativePath": "experiments/bootstapir-book/independent-review.md", + "bytes": 4276, + "sha256": "849b343413af50acab3fbdb98095b65daef77a5a443bce34731aabb26b24be85" + }, + { + "localArchiveRelativePath": "experiments/bootstapir-book/final-manifest.json", + "bytes": 5108, + "sha256": "eecfbf6500a0191b5d9c9abc9c6181bac00f6b9a225ffec26c3f2d6912db7fb9" + }, + { + "localArchiveRelativePath": "experiments/da3-prefix-update/REPORT.md", + "bytes": 8270, + "sha256": "0d9ebcd60a0a4fa85e09b9919fa9d2e7cce685227df0007a28049116dfde2550" + }, + { + "localArchiveRelativePath": "experiments/apple-depth-runtime/README.md", + "bytes": 7200, + "sha256": "a5e0d85923ae53ec6327cec6ef1a72d625814d0fee075b9462f8f617bde2715b" + }, + { + "localArchiveRelativePath": "experiments/tum-fr3-reference-prep/acquisition-prep/REPORT.md", + "bytes": 4680, + "sha256": "36d99f7567d7dd36ce52cc157616082ab66e2b79b4a0147a89ef9cac220c2d21" + }, + { + "localArchiveRelativePath": "experiments/tum-fr3-reference-prep/acquisition-prep/artifact-manifest.json", + "bytes": 200386, + "sha256": "7ce8dc59d2d75cc9830b099779969c7997f917a171313645fe07a838772b66cf" + }, + { + "localArchiveRelativePath": "experiments/tum-fr3-reference-consistency/prepared-manifest.json", + "bytes": 1120, + "sha256": "133358bd1281c13530e1335d4044c831856d550f9643458f96a899bed2c0d2f8" + }, + { + "localArchiveRelativePath": "deployment-input-limits/REPORT.md", + "bytes": 4991, + "sha256": "1e72c6f955242fda08d6e0ea54ca35eb3cf1c7d4dce67fb03639989918973124" + }, + { + "localArchiveRelativePath": "experiments/upload-direct-build/runtime-prep/RUNTIME-RESULT.md", + "bytes": 2636, + "sha256": "7f63c04670ab85431fcf15aad3fede592b160ee2e5753eb20a6d11fdf63dea29" + }, + { + "localArchiveRelativePath": "point-reference-options.md", + "bytes": 7286, + "sha256": "5e3135236423f859f83829e683dcf637f627e2f60632bce1f1faf2fed101e30e" + } + ] +} diff --git a/docs/research/2026-09-26-experiment-evidence.md b/docs/research/2026-09-26-experiment-evidence.md new file mode 100644 index 000000000..10cdf8f3f --- /dev/null +++ b/docs/research/2026-09-26-experiment-evidence.md @@ -0,0 +1,116 @@ +# Experiment evidence at research stop + +September 26, 2026. This is a curated record of the completed investigation. Counts belong to the stated inputs and exact experiment, not a shared leaderboard. Most runtime work used an Apple M5 Mac with 16 GiB memory; no physical-phone performance result was established. + +**Evidence levels:** source/API inspection establishes documented contracts; synthetic execution establishes behavior for constructed cases; real-image execution establishes output on those images; independent physical accuracy requires separate truth. These levels are not interchangeable. Successful audit of saved arithmetic is not a second model or field run. + +## Capture, guidance and evidence handling + +| Investigation | Observed result | Limit / consequence | +| --- | --- | --- | +| ARKit still-capture APIs | Corrected standalone Swift probe typechecked with SDK 26, including returned-frame pose, intrinsics, timestamp and image dimensions | Initial naming error preserved. No camera, concurrency, throughput or thermal validation | +| Native guidance APIs | SDK 26 orientation, projection and window geometry calls typechecked for an iOS 16 deployment target; newer overload negative controls failed as expected | No physical screen alignment. World projection and captured-image display transforms remain different contracts | +| Coverage replay | Synthetic path/frustum examples exposed false-completion cases and exercised unknown/recovery handling | Walking nearby and seeing a surface are different. Analytic scenes do not establish exterior performance | +| Next-view policy | 15 synthetic scenarios across four variants; 60 controls passed, with a separate saved-state/metric review | Five cases had explicit preference labels. Full dwell expiration, users and real navigation remain untested | +| Capture admission | Latest prototype passed 19 producer controls and seven independent regression controls after epoch, revision-race and malformed-ID counterexamples were fixed | Simulated image assessment and bytes. Actual SIGKILL rollback covers one controlled transaction point, not all storage failures | +| Export coherence | 50 expected synthetic outcomes; separate audit covered 408 records across 16 cases | Source identity, references and warnings were checked. Physical endpoints, exposure identity and calibrated uncertainty were not established | +| Camera adapter | Existing [PR #9](https://github.com/SamGu-NRX/house-scanning/pull/9): 14 tests and 100 rotation controls | Coordinate arithmetic and input validation, not device calibration | +| Network replay | 54 primary and 18 additional synthetic controls | Invented workload; no real network, throughput or battery result | + +Published versions of the two reusable state prototypes have their own test instructions and export-validation results under [research-handoff](../../experiments/research-handoff/README.md). + +## Actual native source findings + +The following findings apply to inspected commit [`a39d0a503412d688dd151f628cdeebc00f8221b6`](https://github.com/SamGu-NRX/house-scanning/tree/a39d0a503412d688dd151f628cdeebc00f8221b6), not an automatically moving branch head. Teammates' source was read without modifying their branches. + +### Reset and asynchronous completion + +One Swift build and one extracted-engine harness run produced 18 observations: nine baseline/patch pairs. Four baseline schedules admitted obsolete storage work or published a close-up after reset/phase change. All nine patched cases matched expectations, including normal success/failure and independent draining of old/new save generations. + +The current implementation already uses store epochs and a per-generation pending-save dictionary. The proposal preserves those improvements. It adds task-entry/post-await validity checks and ensures the captured generation drains on early return. An earlier, larger patch against an older source revision is historical and should not be applied wholesale. + +This harness uses exact extracted engine methods with declared completion instrumentation, controlled storage and UI/framework doubles. It does not execute the full iOS app, actual current disk writes, JPEG decoding, camera, OCR or upload. The [published patch](../../experiments/research-handoff/native-proposals/README.md) requires integration review. + +### Ground-height coverage invalidation + +Untouched `Camera`, `WallFrame` and `CoverageMap` sources were compiled into a fixed synthetic probe. Raising ground height from 0 to 0.5 m left the existing map's **14 covered cells** and revision unchanged; rebuilding from the identical observations at the new height produced **zero covered cells**. A no-op update preserved the original state as expected. All 11 predeclared checks passed. + +The upper wall samples moved outside both images, but cached row evidence remained associated with the previous sample positions. Merely incrementing a revision would not fix the evidence. A replacement map may also reuse an old numeric revision, so downstream publication needs an explicit update contract. + +The proposed helper replays qualified cameras in their original acceptance order and retains skip annotations. It was **prepared only**, not compiled or executed. It refuses a nonzero pending horizontal cell shift, a limitation that can occur during normal anchor refinement. It is not a complete app fix, observation ledger or UI integration. + +## Geometry and reconstruction + +| Investigation | Observed result | Interpretation | +| --- | --- | --- | +| MoGeSmall ONNX | CPU compatibility and one small illustrative forward completed; postprocessing had 19 mathematical controls | Not a reconstruction-quality or phone benchmark; PyTorch/ONNX parity remained unresolved | +| MoGe single exterior image | Declared conditional depth discrepancy decreased from 53.6% to 29.9% between two token settings | One image; depth/range and field-of-view conventions remained unresolved. Do not treat as a reliable accuracy score or model ranking | +| DA3 synthetic compatibility | Three-view CPU/MPS outputs were finite; invalid metric-mode usage was identified and preserved | Compatibility only | +| DA3 real views | Three public real images produced 29,400 exported, unfused points in one CPU run; output and viewer data were reviewed | Supplied poses/scale, moving foreground and unresolved reference scan semantics prevent accuracy scoring | +| DA3 added view | Fourth-image batch changed 117,600 shared depth samples: 3.06% raw and 6.51% aligned mean absolute relative revision | Context and alignment changed together. Neither value means improvement or accuracy | +| Apple Core ML depth | One native Mac CPU prediction returned all 203,056 finite FP16 samples; packing/row-stride/output checks passed | This artifact divides each image's learned output by its own maximum. It is a relative cue, not meters; one stretched input and one run | +| COLMAP | Initial database/thread precondition failures retained; subsequent saved-feature continuation yielded 371 raw and 333 verified pair rows, **zero final 3D points** | No successful reconstruction. Correspondence and timestamp audits did not justify silently changing pose associations or rerunning | +| MapAnything | Source/weight-license audit and helper-only execution covering 11 behavior groups | No model weights or inference. Crop, camera conventions, units and output revisions need integration checks | +| TUM reference acquisition | Complete 211,008,272-byte archive acquired; 465 RGB, 454 depth images and 1,554 poses; 451 associated pairs; three fixed views admitted from metadata | Original archive/pose identities checked; visual inspection showed a flat printed-paper scene with blur. Depth consistency and learned-model evaluation remain **unrun** | + +The TUM profile fixes intrinsics, depth scale, timestamps, pose interpolation and three metadata-selected views before evaluation. Its prepared diagnostic would compare all six directed view pairs without fitting calibration or discarding residuals. It was not executed before the stop request. A small indoor planar reference would still not establish exterior-house accuracy. [Publisher dataset documentation](https://cvg.cit.tum.de/data/datasets/rgbd-dataset) + +Streaming claims also need precise interpretation: a stateful lower-level model can be wrapped by code that resets state; accepting chunks does not necessarily mean causal live input. MapAnything, LingBot, MoGe-3 and classical reconstruction remain alternatives in the [resource comparison](2026-09-26-options-and-resources.md). + +## Recognition, tracking and association + +| Investigation | Observed result | Interpretation | +| --- | --- | --- | +| Native OCR | 84 synthetic requests included exact-identifier errors at confidence 1.0 | Confidence saturation does not justify accepting an exact identifier | +| PP-OCR tiny | Same 21 synthetic inputs: 39/68 lines and 2/17 labels in the declared scoring contract | Different score meanings/timing and a source-derived adapter; not a field ranking | +| Vision 26 | 42 requests; document path 47/68 lines and 5/17 labels, with top-1 strings matching legacy on this set; smudge API ran | Synthetic inputs do not validate actual lens contamination or equipment text | +| Two real label photos | Native OCR yielded 19 observations, illustrating split/merged fields | Provisional readings, no independently adjudicated truth or accuracy rate | +| Native barcode | Two exact synthetic strings recovered; separate byte/segment analysis explained original raw-byte mismatches | Original comparison failure retained. No universal payload-format or equipment-identity claim | +| Vision box tracker | 175-frame book run: 160/174 later frames had zero box overlap despite persistent identity/confidence signals | One indoor sequence, initial annotation assistance; not a phone or residential test | +| ViTTrack box tracker | Same sequence: mean box IoU 0.40943 versus Vision 0.03681; 12 false updates counted zero, 43/162 accepted boxes had zero overlap | Better on this clip still includes confident wrong targets; runtime boundaries differ | +| Pairwise SIFT | Fixed 15-feature nomination against nine target views refused all for insufficient matches | No homography fitted; not a universal SIFT failure | +| LightGlue, nomination only | Nine fixed 15-by-500 feature pairs produced no matches | Exact saved features and author checkpoint; not the author's full extraction pipeline | +| LightGlue, full context | Same original source expanded to 500 features: 1,093 full-scene matches; only two matches originated in the fixed source nomination, both paired with frame 175 | Target-object membership was not verified; no correspondence truth or geometry fit. Scene agreement is not nominated-equipment agreement | +| Vision registration | Known synthetic warp returned confidence 1; none of eight declared coordinate families or inverse diagnostics met the fixed residual bounds; best RMS 8.587 px | Registration versus an unmodeled coordinate convention remained unresolved; not an API-wide defect claim | +| Equipment association | Corrected prototype passed 11 synthetic transition cases after independent review found a sibling-ticket race | In-memory state semantics, not physical identity, image resolution or authenticated review | + +### Causal point tracking: useful signal, incomplete availability + +BootsTAPIR at tapnet commit `730cda1c730877cfedbe01bf87fb1cadb78a565d` first passed strict checkpoint loading and two synthetic causal steps. It then completed one continuous CPU run over all **175 original book images**, with eight fixed first-frame queries, no reset, no query replacement and default visibility. Frame 1 was initialization; all 174 subsequent frames remained in the denominator. + +| Outcome | Result | +| --- | ---: | +| Visible and inside target mask, all opportunities | **457 / 1,392 (32.83%)** | +| Inside target mask, conditional on visible | **457 / 458 (99.78%)** | +| Marked not visible | 934 / 1,392 | +| Frames with no visible query | 77 / 174 | +| Visible outside-mask outputs | 1 | +| Whole watched CPU process | 83.80 s | +| Sampled owned-process RSS maximum | 731,660,288 bytes | +| Median prediction boundary | 0.4568 s | + +The timing boundary includes feature extraction, tracking, coordinate conversion and scalar serialization, but excludes image decode and later checks. Sampled memory can miss short peaks. These measurements are not iPhone performance or steady-state FPS. + +Independent saved-output arithmetic reproduced all 1,400 mask memberships, including initialization, the 1,392 evaluation classifications, coordinate ratios and visibility decisions. **Containment is not exact point tracking:** a prediction can slide inside the object's mask. A nonempty object mask does not prove a particular nominated point is visible. Therefore the 751 hidden-but-contained outputs cannot be labeled false occlusions. The already-seen clip, mask-assisted query selection and differing tasks also prevent a fair leaderboard against box IoU. [BootsTAPIR implementation](https://github.com/google-deepmind/tapnet) + +The final source/metadata search found one smaller reference with actual per-point trajectories and occlusion labels: TAP-Vid RGB-Stacking, a publisher-described 50-video synthetic robotics set. Its complete ZIP advertises 187,291,581 bytes and the publisher assigns CC BY 4.0 to videos and annotations. No dataset body was acquired, so expansion, exact useful transitions and evaluation suitability remain uninspected. No complete comparably small real-video option was verified. This is a candidate for a later point-accuracy test, not a new result. [Publisher dataset documentation](https://github.com/google-deepmind/tapnet/tree/730cda1c730877cfedbe01bf87fb1cadb78a565d/tapnet/tapvid) + +## Criteria, integration and transport + +| Investigation | Established | Limit | +| --- | --- | --- | +| Placement intervals | Seven synthetic scenarios and 3,315 finite oracle comparisons exposed a coarse-grid miss | One-dimensional hard-bound assumptions; no calibrated physical uncertainty | +| Measurement interoperability | 30 paired synthetic CLI cases plus saved-output review | Units, equality, missing evidence and validator scopes; no image inspection or measurement-endpoint truth | +| One-wall handoff adapter | 23 methods / 30 calls, two parser controls and nine reply checks; 10 provisional and 20 withheld | Synthetic coordinate epochs and a separate measurement path, not production capture integration | +| Server pruning review | Actual source reproduced overly aggressive pruning; isolated correction tested; newer team source independently fixed the original default case | A remaining extreme-drift boundary was a stress case, not an observed household outcome. No whole-site/API accuracy result | +| Deployment observation | Official 4.5 MB Function envelope; three live GETs for health and schemas; served schema bytes matched inspected source | No deployed-code attestation, placement POST, media upload or reconstruction service | +| TUSKit upload work | Initial build exceeded its cap; a separate direct build produced client/library artifacts but exposed an SDK identity mismatch | A later explicitly scoped runtime attempt stopped at process-monitor permission denial **before** server, client or payload. No transfer ran | +| Apple segmentation | Actual model-interface inspection and prompt-harness controls completed | Prompt-only Core ML attempt stopped at a working-directory permission denial before model load/prediction. No mask-quality result | + +Failed and denied attempts were preserved. No alternate permission path was used to bypass them. No paid GPU was provisioned. The user stop arrived before running the ground replay proposal, the prepared TUM depth diagnostic or any further research candidates. + +## Publication and reproducibility + +The checked-in [experiment packages](../../experiments/research-handoff/README.md) can be reviewed and tested independently. The native patches identify their precise public baseline and remain proposals. Raw local experiment trees retain frozen inputs, source/model identities, output records and reviews, but are not published here because they include third-party media, model files, build artifacts or private context. + +The accompanying [evidence index](2026-09-26-evidence-index.json) records SHA-256 identities of selected locally retained reports and manifests. Those hashes identify the research snapshot; they are **not** a substitute for the unshipped evidence or an independently reproducible benchmark. The high-level [handoff](../06-research-handoff.md) gives the recommended next decisions and field test. diff --git a/docs/research/2026-09-26-options-and-resources.md b/docs/research/2026-09-26-options-and-resources.md new file mode 100644 index 000000000..5c1ab95f5 --- /dev/null +++ b/docs/research/2026-09-26-options-and-resources.md @@ -0,0 +1,95 @@ +# Capture, guidance and reconstruction: options and resources + +Research snapshot: **September 26, 2026**. This page consolidates existing primary-source reviews and bounded local experiments. It contains no private survey criteria or customer imagery. Publisher results, local execution and proposed integrations are different kinds of evidence; none of the work establishes a validated exterior-house survey on a phone. + +Read the [high-level handoff](../06-research-handoff.md) for the overall recommendation and the [experiment ledger](2026-09-26-experiment-evidence.md) for individual outcomes. This page explains the alternatives and links their primary resources. + +**Recommended direction:** native iPhone capture, optional depth, local quality and guidance, durable selected photographs, asynchronous server interpretation, and explicit geometric checks. Keep a simple reviewable scene ahead of a photorealistic model. Retain Android and browser paths as meaningful alternatives with different evidence capabilities. + +Evidence labels below: + +- **Local:** the stated code or model path actually ran on this Mac. The fixture and scope still matter. +- **Source:** official documentation, pinned source or artifact contracts were inspected; the product integration was not run. +- **Proposed:** a design choice or next comparison, not an observed result. +- **Blocked / empty:** a preserved outcome, not a successful benchmark or proof that the underlying method is unsuitable. + +## 1. Capture the exterior and useful detail + +| Path | What distinguishes it | Recommendation and evidence | +|---|---|---| +| **Swift + ARKit / RealityKit** | One native session provides images, camera poses, calibration and tracking state. Supported devices can add depth and scene meshes. | **Primary path. Source + API typechecks.** Own the camera, buffers, frame metadata and overlays in native code. No physical-phone capture, outdoor accuracy or thermal result was obtained. [Tracking lifecycle](https://developer.apple.com/documentation/arkit/managing-session-life-cycle-and-tracking-quality), [scene depth](https://developer.apple.com/documentation/arkit/arframe/scenedepth). | +| **Object Capture area mode** | Automatic photos, capture feedback and a point-cloud UI for surfaces that cannot be circled. | **Native alternative for a geometry demo. Source/API inspection.** Apple's sample requires supported LiDAR hardware. The inspected public session API does not expose the same per-frame image/pose feed as an app-owned ARSession; capture and photogrammetry are separate phases. [WWDC24 area mode](https://developer.apple.com/videos/play/wwdc2024/10107/), [sample](https://developer.apple.com/documentation/realitykit/scanning-objects-using-object-capture). | +| **Android ARCore** | Tracking, planes, calibration and optional depth; supported devices can compute depth without a dedicated depth sensor. | **Credible expansion path. Source only.** Check tracking and depth support separately. Raw depth can contain holes or reproject earlier observations; it is not a ready environment mesh or an independent new measurement every frame. [Raw depth](https://developers.google.com/ar/develop/java/depth/raw-depth), [shared camera](https://developers.google.com/ar/develop/java/camera-sharing). | +| **Browser photo coaching** | Link-based entry, camera preview, framing instructions and asynchronous analysis with less installation friction. | **Useful reduced-capability alternative. Source only.** Ordinary iPhone Safari was not established as a native WebXR/ARKit pose or LiDAR-export path. Chrome Android has an ARCore-backed WebXR path, but photographic evidence/calibration export still needs a separate check. [WebKit issue](https://bugs.webkit.org/show_bug.cgi?id=309550), [Android WebXR](https://developers.google.com/ar/develop/webxr). | +| **Expo shell + native capture module** | Retains React Native screens while Swift owns the complete AR capture view and data stream. | **Integration alternative, not removal of native work. Source only.** Use a development build; Expo Go cannot load arbitrary custom native code. Send compact events/file references to JavaScript rather than every camera buffer. [Expo native code](https://docs.expo.dev/workflow/customizing/). | + +Ordinary ARKit tracking and LiDAR are different capability tiers. Do not require a depth sensor merely to show spatial cues, and do not infer measured free space when depth is missing. Save each selected photograph with **its own** calibration, dimensions, timestamp and pose. ARKit's [high-resolution frame API](https://developer.apple.com/documentation/arkit/arsession/capturehighresolutionframe(completion:)) can deliver an asynchronous still during tracking; format support and the returned frame must be checked. + +Useful open-source references include [Stray Scanner](https://github.com/strayrobots/scanner) (MIT; inspected recorder requires scene depth), [NeRFCapture](https://github.com/jc211/NeRFCapture) (MIT; optional-depth capture reference), and [arkit-3dgs-scanner](https://github.com/KuoFengYuan/arkit-3dgs-scanner) (Apache-2.0; automatic selection/quality code). None is a locally validated turnkey survey app. + +For distribution, an [App Clip can use ARKit](https://developer.apple.com/documentation/appclip/interacting-with-app-clip-codes-in-ar), but its [capability limits](https://developer.apple.com/documentation/appclip/choosing-the-right-functionality-for-your-app-clip) include no background URLSession activity. Browser alternatives also need lifecycle testing. The 2026 [8th Wall release](https://8thwall.org/docs/open-source) opens its MIT framework, **not its SLAM engine**: the separate binary has a [different license](https://github.com/8thwall/engine/blob/main/LICENSE). [AlvaAR](https://github.com/alanross/AlvaAR) is a GPLv3 browser visual-SLAM research alternative, not a demonstrated native-equivalent phone survey engine. + +## 2. Guide the person and choose photographs + +**Recommended baseline:** combine current task needs, tracking quality, image quality and accepted-view history. Keep “move to another viewpoint” separate from “aim here,” “hold still” and “recover tracking.” A useful preview requests a still; progress advances only after the returned image and metadata are accepted and durably saved. + +| Component | Useful resource or approach | Evidence and remaining gap | +|---|---|---| +| Blur, exposure, text size and motion checks | Native image statistics, AR tracking state and sampled [Vision OCR](https://developer.apple.com/documentation/vision/vnrecognizetextrequest). | **Source + Mac OCR probes.** No universal phone-quality threshold was established. High OCR confidence can accompany a wrong identifier. | +| Task-aware capture admission | Bounded preview analysis; one capture in flight; reassess the actual still; persist before crediting progress. | **Local synthetic protocol controls.** Save failures and interrupted transactions did not become successful captures in the tested prototype. This does not measure camera/still latency or image usefulness. | +| Fog of war | A ledger of observed surface patches and unresolved evidence tasks, supported by visible saved images. | **Local synthetic replay.** Walking a route or placing a surface inside the camera frustum does not prove that surface was seen through an occluder or at usable quality. | +| Viewpoint selection | Small candidate-view search with accepted viewing-direction history, a stable target and recovery preconditions. | **Local synthetic replay.** Hysteresis reduced scripted target switching; a wrong predicted footprint could make look-ahead worse. No homeowner-effort improvement was measured. | +| Visibility and unknown space | [Open3D ray queries](https://www.open3d.org/docs/latest/python_api/open3d.t.geometry.RaycastingScene.html); [OctoMap](https://octomap.github.io/) for free/occupied/unknown mapping. | **Source; simple synthetic geometry also exercised.** A partial mesh limits visibility claims. Open3D is MIT; OctoMap core is BSD, while its viewer has different terms. Neither is a complete phone guidance system. | +| Screen-space arrows/reticles | ARCamera projection using the correct orientation, viewport and frame; suppress unsupported world cues after a coordinate reset. | **Local API typecheck + source contract.** Signatures compiled; physical camera alignment and live cue stability remain untested. A simulator cannot validate real camera/VIO. | + +Learned next-best-view systems remain alternatives, not necessary dependencies. [SEE++](https://github.com/robotic-esp/see-public) offers an MIT point-density/frontier approach with a robotics stack. [GenNBV](https://github.com/zjwzcx/GenNBV) supplies learned planning code and checkpoints, but uses a drone action space, substantial GPU/simulator resources and CC-BY-NC-SA terms. Neither was run locally. Their published benchmarks do not establish safe, accessible movement instructions for a person around a house. + +## 3. Reconstruct and visualize + +Use a versioned, inspectable geometry representation for measurements and a separate rendering asset for review. Metric units, photorealism, model confidence and additional photographs do not by themselves establish metric accuracy. + +| Option | Actual update/output contract | Recommendation, local status and license boundary | +|---|---|---| +| **ARKit planes / optional mesh** | Device tracking and geometry updates during capture. | **Primary live spatial reference. Source/API evidence; no phone validation.** Apple platform SDK, not open-source model weights. Missing geometry remains unknown. | +| **DA3-Small** | Multi-view batch depth/camera inference; the separate streaming wrapper uses overlapping chunks and alignment. | **Small reconstruction challenger. Local CPU/MPS and real-image probes completed.** Adding a fourth view revised existing outputs, so batch results need revision handling. Small/Base weights are Apache-2.0; several larger variants are noncommercial. [Official project/model table](https://github.com/ByteDance-Seed/Depth-Anything-3). | +| **MapAnything Apache** | Set-based inference conditioned on images and optional calibration, poses or depth. Supplied geometry is conditioning, not a guaranteed fixed output. | **Leading larger calibrated-input challenger. Source/header audit + local preprocessing helpers only; no model forward.** Explicitly select [Apache weights](https://huggingface.co/facebook/map-anything-apache); the default other checkpoint is noncommercial. Roughly 4.58 GiB of F32 weights is not a measured VRAM requirement. [Code](https://github.com/facebookresearch/map-anything). | +| **LingBot-Map** | Actual causal frame processing with retained state after initialization. The demo wrapper resets state at the beginning of a run. | **Streaming research candidate. Source only.** A service must own persistent session state rather than repeatedly call the sequence wrapper. Documented CUDA/FlashInfer route; no local MPS/phone path demonstrated. Code and official weights declare Apache-2.0. [Project](https://github.com/Robbyant/lingbot-map), [weights](https://huggingface.co/robbyant/lingbot-map). | +| **MoGe-2 Small** | Single-image geometry, normals and scale-related outputs; tracking/fusion are separate. | **Compact geometry comparator. Local CPU ONNX and one exterior reference-discrepancy probe completed.** Export postprocessing and reference semantics matter; these runs did not establish house-survey accuracy. Official PyTorch weights declare MIT; retain the ONNX artifact's separate provenance. Newer MoGe-3 documents a different runtime and no macOS support. [Project](https://github.com/microsoft/MoGe), [Small weights](https://huggingface.co/Ruicheng/moge-2-vits-normal). | +| **COLMAP 4.2** | Established sparse feature reconstruction; dense geometry requires further stages. Known poses or external scale can be supplied, but do not validate themselves. | **Classical baseline. Local fixed-pose matching completed with an empty final sparse model.** Preserve that result and earlier setup failures; it is not evidence that COLMAP fails on all houses. BSD core; learned components have separate licenses/build requirements. [Tutorial](https://colmap.github.io/tutorial.html), [4.2 release](https://github.com/colmap/colmap/releases/tag/4.2.0). | +| **Apple Depth Anything V2 Small Core ML** | An image-depth package whose inspected graph normalizes by the image's own maximum. | **Native auxiliary-depth candidate. One Mac CPU compile/load/prediction completed.** It did not establish metric depth, near/far interpretation or iPhone behavior. [Official package](https://huggingface.co/apple/coreml-depth-anything-v2-small). | + +Start review visuals with points, planes, footprints and evidence links. [RealityKit LowLevelMesh](https://developer.apple.com/documentation/realitykit/lowlevelmesh) and [Three.js](https://threejs.org/docs/) support changing geometry. [Rerun](https://rerun.io/docs/reference/sdk/operating-modes) is useful for synchronized engineering timelines; it is not the durable evidence store. [Spark](https://sparkjs.dev/docs/lod-getting-started/) can stream prepared Gaussian assets for rich browser review, but that is different from incrementally reconstructing the scene. Three.js/Spark are MIT; Rerun offers MIT/Apache licensing. These viewer integrations were source-reviewed, not delivered as an integrated live product. Splats are a visual representation, not guaranteed solid collision or clearance geometry. + +## 4. Recognize evidence, then apply explicit checks + +Recognition should produce observations linked to original images and geometry. A deterministic evaluator then consumes supported measurements, applicable published/configured rules and explicit uncertainty. A class score, mask score, tracking score and measurement-error bound are different quantities. Preserve unresolved observations and review paths rather than turning missing evidence into a favorable result. + +| Need | Recommended baseline and alternatives | Evidence and adoption limit | +|---|---|---| +| Exact label text | **Apple Vision** locally; [PP-OCRv6 tiny](https://huggingface.co/PaddlePaddle/PP-OCRv6_tiny_rec) as a specialist open-source comparator. | **Both ran on the same generic synthetic fixtures on Mac.** Both produced high-score exact-string errors. PP-OCR requires detection, crop processing and recognition; its checked code/model cards are Apache-2.0. This is not field identifier accuracy or a phone-speed ranking. | +| Equipment proposals and masks | User nomination plus [Grounding DINO](https://github.com/IDEA-Research/GroundingDINO) / [SAM 2.1](https://github.com/facebookresearch/sam2); newer [SAM 3.1](https://huggingface.co/facebook/sam3.1) as a server challenger. | **Source only for these full pipelines.** DINO/SAM 2 baseline artifacts have Apache terms. SAM 3 uses a custom gated license and a larger GPU-oriented stack. A semantic phrase or mask does not prove equipment identity or absence. | +| Native image segmentation | [Apple's SAM 2.1 Tiny packages](https://huggingface.co/apple/coreml-sam2.1-tiny/tree/39ae0a8a83e5e6cd196e804bf7cccc5f8171f306); [EdgeTAM](https://github.com/facebookresearch/EdgeTAM) and [YOLOE](https://github.com/THU-MIG/yoloe) as alternatives. | **Artifact/source audits; native prompt-model execution blocked before prediction by the declared sandbox.** The Apple package card declares Apache-2.0. That is not a mask-quality failure. EdgeTAM's reviewed Core ML export omitted temporal memory and had preprocessing/input issues. YOLOE can fuse a fixed vocabulary for export, but has AGPL terms and text-model dependencies. No phone mask pipeline was validated. | +| Carry a nominated region across frames | Vision object tracking; [OpenCV ViTTrack](https://github.com/opencv/opencv_zoo/tree/47534e27c9851bb1128ccc0102f1145e27f23f98/models/object_tracking_vittrack); causal [BootsTAPIR](https://github.com/google-deepmind/tapnet). | **All ran locally on the same already-seen book clip, with different tasks.** Vision's confidence did not reliably flag observed box loss. ViTTrack returned rejected updates that must not become fresh boxes. BootsTAPIR's visible point containment was 457/1,392 overall, alongside 457/458 among declared-visible outputs—not point accuracy or box IoU. ViTTrack Zoo files and current TAPNet code/checkpoint declarations are Apache-2.0. | +| Other point-tracking alternatives | [CoTracker3](https://github.com/facebookresearch/co-tracker) and [TAPNext++](https://github.com/google-deepmind/tapnet/tree/730cda1c730877cfedbe01bf87fb1cadb78a565d/tapnet/tapnextpp). | **Source only.** CoTracker online uses windows with possible within-window look-ahead and noncommercial code/weights. TAPNext++ has a causal recurrent path and Apache declarations, but its reviewed checkpoint is much larger. Neither establishes phone deployment or physical identity. | +| Barcode corroboration | Native Vision, with [ZXing-C++](https://github.com/zxing-cpp/zxing-cpp) as an Apache-2.0 alternative. | **Local synthetic native observations plus source review.** Preserve decoded string and raw payload semantics separately; raw barcode bytes were not interchangeable with encoder UTF-8. A correctly decoded code still needs association and interpretation. | + +Object masks do not supply visibility truth for each tracked physical point. Likewise, a label associated to the wrong enclosure remains wrong even when its transcription is exact. Coordinate transforms, timestamps, session/reconstruction revisions and source-image association are part of the evidence path, not optional rendering details. + +## Hybrid architecture and uploads + +**Recommended split:** keep preview, tracking recovery, aiming and capture acknowledgment on the device; send selected evidence for asynchronous interpretation; refine heavier geometry/render assets separately. A device-led deferred workflow is the robust offline alternative. A cloud-led stream is an experiment option when bandwidth and latency permit, not a prerequisite for immediate guidance. + +The local network replay used **hypothetical workloads and latencies**. It showed why durable originals need their own queue, lost acknowledgments need idempotent reconciliation, and stale guidance should be rejected by task/coordinate dependencies. Requiring every reply to mention the newest photograph can also starve useful older findings. These are protocol observations, not measured carrier performance. + +| Transport / work type | Useful resource | Decision and status | +|---|---|---| +| Durable selected images | [Background URLSession](https://developer.apple.com/documentation/foundation/urlsessionconfiguration/background(withidentifier:)); file-backed uploads. | **Native baseline; source evidence.** OS scheduling and force-quit behavior matter. Background transfer does not keep AR capture running. | +| Resumable uploads | [TUSKit](https://github.com/tus/TUSKit), [tusd](https://github.com/tus/tusd), both MIT; [Supabase tus endpoint](https://supabase.com/docs/guides/storage/uploads/resumable-uploads). | **Candidate transport; no successful recovery-transfer result.** Local work preserved build/resource and later monitoring-permission blockers. tus and Foundation's HTTPbis resume are different protocols. Service chunk rules and iOS background scheduling still need actual integration tests. | +| Current whole-bundle service | Keep an explicit completed-asset handoff from any future resumable transport. | **Source inspection at the reviewed service revision.** A ZIP/multipart request is not already a resumable or incremental reconstruction API. | +| Periodic versus stateful reconstruction | Coalesce replaceable batch jobs; preserve ordered, deduplicated accepted inputs for a stateful model. | **Proposed architecture with synthetic controls.** Reconnection needs replay/checkpoint semantics; a socket reconnect does not restore model state. | + +## What would change the recommendation + +A consented physical-phone experiment remains the highest-value missing evidence: synchronized images/poses/calibration, ordinary and depth-capable capture where available, known scene measurements, glare/blank surfaces, tracking interruption and offline recovery. Simulator and desktop success cannot establish camera/VIO behavior. + +Before that hardware is available, the useful next checks are a small move-versus-aim replay, a known-pose display/coordinate-reset replay, and a capability-limited walkthrough that keeps image-only evidence distinct from measured geometry. More model demos on the same seen clip would not resolve those integration gaps. All candidate licenses should be checked at the exact artifact and dependency versions selected for distribution; permissive code does not automatically license weights, datasets or hosted services. diff --git a/experiments/README.md b/experiments/README.md index b47eacaf9..60a40d7f8 100644 --- a/experiments/README.md +++ b/experiments/README.md @@ -2,6 +2,8 @@ An experiment answers one question the team needs settled before building on an assumption. For example: can an iPhone without LiDAR measure the gap between a wall and a fence to within a few inches? +The [research handoff packages](research-handoff/README.md) contain reusable capture/evidence prototypes and isolated native proposals from the September 26 investigation. Start with the [high-level report](../docs/06-research-handoff.md) for their results and limitations. + - Give each experiment its own folder, `experiments//`. - Its README states the question, the method and the pass criteria before the run, then the result and what it changes after. Keep the result when it fails; a failure tells us which part to change next. - Keep dependencies inside the folder, in its own uv, pnpm or Xcode project. Don't add them to `server/`, `web/` or `ios/`. diff --git a/experiments/research-handoff/README.md b/experiments/research-handoff/README.md new file mode 100644 index 000000000..9b0cc2b61 --- /dev/null +++ b/experiments/research-handoff/README.md @@ -0,0 +1,13 @@ +# Reusable prototypes from the live-survey research + +These isolated experiments accompany the [research handoff](../../docs/06-research-handoff.md). They do not change the iOS app, server, web client or team-owned branches. + +| Package | Purpose | State | +| --- | --- | --- | +| [capture-admission](capture-admission/README.md) | Credit progress only after the actual returned evidence is durably admitted under valid task/coordinate revisions | Executed synthetic prototype; see package test results | +| [equipment-association](equipment-association/README.md) | Keep observed labels, equipment candidates and reviewed associations distinct | Executed synthetic prototype; see package test results | +| [native-proposals](native-proposals/README.md) | Narrow stale-task fix and separate ground-coverage replay helper | Reset change tested in an isolated harness; ground helper prepared only; neither integrated | + +Python packages use the standard library and temporary synthetic data. Their READMEs give exact commands and boundaries. Native patches target a pinned public source revision and are not automatically applied or built. + +Downloaded weights/media, customer information, private criteria, build caches and local run receipts are intentionally excluded. Aggregate historical outcomes and failures remain visible in the [evidence report](../../docs/research/2026-09-26-experiment-evidence.md). diff --git a/experiments/research-handoff/capture-admission/.gitignore b/experiments/research-handoff/capture-admission/.gitignore new file mode 100644 index 000000000..4ebc28107 --- /dev/null +++ b/experiments/research-handoff/capture-admission/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +test-receipt.json diff --git a/experiments/research-handoff/capture-admission/README.md b/experiments/research-handoff/capture-admission/README.md new file mode 100644 index 000000000..7e51e6aac --- /dev/null +++ b/experiments/research-handoff/capture-admission/README.md @@ -0,0 +1,15 @@ +# Capture admission prototype + +A requested photo must not become completed capture progress until the returned bytes, returned-frame metadata and task assessment commit together. This standalone SQLite prototype separates capture requests, durable evidence, current requirement support and server acknowledgment. + +The published version includes the reviewed fixes: persistent epoch history, current-epoch spatial admission, strict runtime types, transactional requirement updates, consistent read snapshots and refusal to publish uncommitted progress. Earlier tests missed epoch reuse/future-epoch activation and a two-writer revision race; this export contains the corrected implementation, not that historical version. + +Run from the repository root with Python 3.10+ on macOS or Linux; only the standard library is used: + +```sh +python3 -B -m unittest discover -s experiments/research-handoff/capture-admission -p 'test_*.py' -v +``` + +Export validation on Python 3.13.7: **26 tests passed** (19 producer controls plus 7 existing independent regression controls). The tests use temporary databases, scheduled concurrent connections and one owned subprocess killed before commit. The regression module retains the existing test bodies with the original review-output/inventory wrapper removed. The implementation and producer test bodies are unchanged. Running `test_admission.py` directly also writes an ignored local receipt. + +`usable` and task support are supplied assessments; this code does not establish photo quality, OCR, geometry or installation eligibility. Progress is distinct from upload completion and independent-view count. Matching a local hash/length does not prove remote durable storage. SQLite process-crash rollback at one seam is not a phone power-loss guarantee. The byte-BLOB store, orphan recovery and schema are experiment choices, with no production migration or native camera adapter. No app/server integration, customer media or business thresholds are included. diff --git a/experiments/research-handoff/capture-admission/admission.py b/experiments/research-handoff/capture-admission/admission.py new file mode 100644 index 000000000..8ed9e022c --- /dev/null +++ b/experiments/research-handoff/capture-admission/admission.py @@ -0,0 +1,223 @@ +"""Synthetic capture-admission reference; upstream assessment is NOT verified here.""" +from __future__ import annotations + +import hashlib +import json +import math +import sqlite3 +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Callable + + +class Conflict(ValueError): + pass + + +def valid_id(value): + return isinstance(value, str) and bool(value.strip()) + + +@dataclass(frozen=True) +class ReturnedStill: + frame_id: str + timestamp: float + epoch: str + tracking: str + usable: bool + # Pairs are (requirement ID, exact assessed revision), not inferred from request. + supports: tuple[tuple[str, int], ...] + + def encoded(self) -> str: + if not valid_id(self.frame_id) or not valid_id(self.epoch): + raise ValueError("Returned-frame identity/epoch must be nonempty strings") + if type(self.timestamp) not in (int, float) or not math.isfinite(self.timestamp) or self.timestamp < 0: + raise ValueError("Returned-frame identity/epoch/finite timestamp required") + if not isinstance(self.tracking, str) or self.tracking not in {"normal", "limited", "unavailable"}: + raise ValueError("Unknown tracking state") + if type(self.usable) is not bool: + raise ValueError("Assessment must explicitly state usable") + if not isinstance(self.supports, tuple) or any( + not isinstance(pair, tuple) or len(pair) != 2 or not valid_id(pair[0]) + or type(pair[1]) is not int or pair[1] < 1 for pair in self.supports + ): + raise ValueError("Invalid assessed requirement revision") + d = asdict(self) + d["timestamp"] = float(self.timestamp) + d["supports"] = sorted(set(self.supports)) + return json.dumps(d, sort_keys=True, separators=(",", ":"), allow_nan=False) + + +class Ledger: + def __init__(self, path: Path): + self.db = sqlite3.connect(path, isolation_level=None) + self.db.execute("PRAGMA foreign_keys=ON") + self.db.execute("PRAGMA journal_mode=WAL") + self.db.execute("PRAGMA synchronous=FULL") + self.db.executescript(""" + CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS epochs(id TEXT PRIMARY KEY); + INSERT OR IGNORE INTO epochs SELECT value FROM settings WHERE key='epoch'; + CREATE TABLE IF NOT EXISTS requirements( + id TEXT PRIMARY KEY, revision INTEGER NOT NULL, kind TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS requests( + id TEXT PRIMARY KEY, target TEXT NOT NULL, revision INTEGER NOT NULL, + preview_frame TEXT NOT NULL, epoch TEXT NOT NULL, status TEXT NOT NULL, + still_metadata TEXT, asset_hash TEXT); + CREATE UNIQUE INDEX IF NOT EXISTS one_pending_capture + ON requests(status) WHERE status='pending'; + CREATE TABLE IF NOT EXISTS assets( + hash TEXT PRIMARY KEY, payload BLOB NOT NULL, server_accepted INTEGER NOT NULL DEFAULT 0); + CREATE TABLE IF NOT EXISTS support( + capture_id TEXT NOT NULL REFERENCES requests(id), + requirement_id TEXT NOT NULL, revision INTEGER NOT NULL, + PRIMARY KEY(capture_id, requirement_id, revision)); + """) + + def close(self): + self.db.close() + + def _transaction(self, operation): + self.db.execute("BEGIN IMMEDIATE") + try: + result = operation() + self.db.execute("COMMIT") + return result + except BaseException: + self.db.execute("ROLLBACK") + raise + + def _read_view(self, operation): + if self.db.in_transaction: + raise Conflict("Progress is unavailable inside an uncommitted write") + self.db.execute("BEGIN") + try: + result = operation() + self.db.execute("COMMIT") + return result + except BaseException: + self.db.execute("ROLLBACK") + raise + + def set_epoch(self, epoch: str): + """Start a fresh coordinate epoch. Re-registration into old maps is not modeled.""" + if not valid_id(epoch): + raise ValueError("Epoch is required") + def op(): + current = self.db.execute("SELECT value FROM settings WHERE key='epoch'").fetchone() + if current and current[0] == epoch: + return + if self.db.execute("SELECT 1 FROM epochs WHERE id=?", (epoch,)).fetchone(): + raise Conflict("Epoch ID cannot be reused without explicit registration") + self.db.execute("INSERT INTO epochs VALUES(?)", (epoch,)) + self.db.execute("INSERT OR REPLACE INTO settings VALUES('epoch',?)", (epoch,)) + self._transaction(op) + + def require(self, key: str, revision: int, kind: str): + if not valid_id(key) or type(revision) is not int or revision < 1 or not isinstance(kind, str) or kind not in {"detail", "spatial"}: + raise ValueError("Invalid requirement") + def op(): + old = self.db.execute("SELECT revision,kind FROM requirements WHERE id=?", (key,)).fetchone() + if old and (revision < old[0] or (revision == old[0] and kind != old[1])): + raise Conflict("A changed requirement needs a newer revision") + self.db.execute("INSERT OR REPLACE INTO requirements VALUES(?,?,?)", (key, revision, kind)) + self._transaction(op) + + def request(self, capture_id: str, target: str, preview_frame: str): + if not all(valid_id(x) for x in (capture_id, target, preview_frame)): + raise ValueError("Capture and preview IDs required") + def op(): + task = self.db.execute("SELECT revision FROM requirements WHERE id=?", (target,)).fetchone() + epoch = self.db.execute("SELECT value FROM settings WHERE key='epoch'").fetchone() + if not task or not epoch: + raise ValueError("Current requirement and epoch required") + identity = (target, task[0], preview_frame, epoch[0]) + old = self.db.execute("SELECT target,revision,preview_frame,epoch FROM requests WHERE id=?", (capture_id,)).fetchone() + if old: + if old != identity: + raise Conflict("Capture ID was reused for different intent") + return "duplicate_request" + self.db.execute("INSERT INTO requests(id,target,revision,preview_frame,epoch,status) VALUES(?,?,?,?,?,'pending')", (capture_id, *identity)) + return "requested" + return self._transaction(op) + + def receive(self, capture_id: str, payload: bytes, still: ReturnedStill, + before_commit: Callable[[], None] | None = None): + """Fault hook is an experiment seam. Payload is opaque; no quality/OCR is inferred.""" + if not valid_id(capture_id) or not isinstance(still, ReturnedStill): + raise ValueError("Capture ID and returned-still record required") + if not isinstance(payload, bytes) or not payload: + raise ValueError("Nonempty returned bytes required") + metadata = still.encoded() + digest = hashlib.sha256(payload).hexdigest() + def op(): + prior = self.db.execute("SELECT status,still_metadata,asset_hash FROM requests WHERE id=?", (capture_id,)).fetchone() + if not prior: + raise Conflict("Unknown capture callback") + status, old_metadata, old_hash = prior + if status in {"admitted", "rejected"}: + if (old_metadata, old_hash) != (metadata, digest): + raise Conflict("Conflicting callback for completed capture") + return "duplicate_callback" + if status != "pending": + raise Conflict("Native capture was interrupted; callback requires explicit recovery") + new_status = "admitted" if still.usable else "rejected" + # Retain source bytes for either decision in this tiny audit prototype. + self.db.execute("INSERT OR IGNORE INTO assets(hash,payload) VALUES(?,?)", (digest, payload)) + self.db.execute("UPDATE requests SET status=?,still_metadata=?,asset_hash=? WHERE id=?", (new_status, metadata, digest, capture_id)) + if still.usable: + for key, revision in set(still.supports): + # An unrecognized assessment must not retroactively satisfy a + # requirement introduced later under the same identifier. + self.db.execute("""INSERT INTO support + SELECT ?,id,revision FROM requirements WHERE id=? AND revision=? + AND (kind='detail' OR (kind='spatial' AND ?='normal' + AND ?=(SELECT value FROM settings WHERE key='epoch')))""", + (capture_id, key, revision, still.tracking, still.epoch)) + if before_commit: + before_commit() + return new_status + return self._transaction(op) + + def recover_orphaned_requests(self): + """Called only after the app establishes no corresponding native operations survive.""" + return self.db.execute("UPDATE requests SET status='interrupted' WHERE status='pending'").rowcount + + def acknowledge(self, digest: str, byte_count: int): + if not valid_id(digest): + raise Conflict("Asset hash must be a string") + def op(): + asset = self.db.execute("SELECT length(payload) FROM assets WHERE hash=?", (digest,)).fetchone() + if type(byte_count) is not int or not asset or byte_count != asset[0]: + raise Conflict("Server acceptance does not identify a committed asset") + self.db.execute("UPDATE assets SET server_accepted=1 WHERE hash=?", (digest,)) + self._transaction(op) + + def progress(self): + return self._read_view(self._progress) + + def _progress(self): + epoch = self.db.execute("SELECT value FROM settings WHERE key='epoch'").fetchone() + result = {} + for key, revision, kind in self.db.execute("SELECT id,revision,kind FROM requirements ORDER BY id").fetchall(): + candidates = self.db.execute("""SELECT r.asset_hash,r.still_metadata FROM support s + JOIN requests r ON r.id=s.capture_id JOIN assets a ON a.hash=r.asset_hash + WHERE s.requirement_id=? AND s.revision=? AND r.status='admitted'""", (key, revision)).fetchall() + assets = set() + for digest, encoded in candidates: + frame = json.loads(encoded) + if kind == "spatial" and (not epoch or frame["epoch"] != epoch[0] or frame["tracking"] != "normal"): + continue + assets.add(digest) + result[key] = {"revision": revision, "kind": kind, "captured": bool(assets), + "unique_assets": len(assets), "spatial_epoch": epoch[0] if epoch and kind == "spatial" else None} + return result + + def snapshot(self): + def read(): + progress = self._progress() + epoch = self.db.execute("SELECT value FROM settings WHERE key='epoch'").fetchone() + return {"progress": progress, "epoch": epoch[0] if epoch else None, + "requests": self.db.execute("SELECT id,status,preview_frame,still_metadata,asset_hash FROM requests ORDER BY id").fetchall(), + "assets": self.db.execute("SELECT hash,length(payload),server_accepted FROM assets ORDER BY hash").fetchall()} + return self._read_view(read) diff --git a/experiments/research-handoff/capture-admission/test_admission.py b/experiments/research-handoff/capture-admission/test_admission.py new file mode 100644 index 000000000..43527e4d6 --- /dev/null +++ b/experiments/research-handoff/capture-admission/test_admission.py @@ -0,0 +1,307 @@ +"""Fault controls for the protocol, not perception or installation tests.""" +from dataclasses import replace +import hashlib +import json +from pathlib import Path +import signal +import sqlite3 +import subprocess +import sys +import tempfile +import threading +import time +import unittest + +from admission import Conflict, Ledger, ReturnedStill + + +def still(**changes): + base = ReturnedStill("actual-frame-107", 10.7, "epoch-a", "normal", True, + (("detail-a", 1), ("wall-a", 1))) + return replace(base, **changes) + + +class AdmissionControls(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.path = Path(self.tmp.name) / "survey.sqlite" + self.db = Ledger(self.path) + self.db.set_epoch("epoch-a") + self.db.require("detail-a", 1, "detail") + self.db.require("detail-b", 1, "detail") + self.db.require("wall-a", 1, "spatial") + self.db.request("capture-1", "detail-a", "preview-frame-100") + + def tearDown(self): + self.db.close() + self.tmp.cleanup() + + def reopen(self): + self.db.close() + self.db = Ledger(self.path) + + def test_good_preview_bad_returned_still(self): + # The request itself represents an eligible preview, never an admission. + self.assertFalse(self.db.progress()["detail-a"]["captured"]) + self.assertEqual(self.db.receive("capture-1", b"blurry source", still(usable=False)), "rejected") + self.assertFalse(any(v["captured"] for v in self.db.progress().values())) + self.assertEqual(len(self.db.snapshot()["assets"]), 1) + + def test_actual_still_metadata_and_local_progress_before_upload(self): + self.db.receive("capture-1", b"actual source", still()) + record = self.db.snapshot()["requests"][0] + self.assertEqual(record[2], "preview-frame-100") + self.assertEqual(json.loads(record[3])["frame_id"], "actual-frame-107") + self.assertEqual(json.loads(record[3])["timestamp"], 10.7) + self.assertTrue(self.db.progress()["detail-a"]["captured"]) + self.assertEqual(self.db.snapshot()["assets"][0][2], 0) + + def test_save_exception_rolls_back_and_retry_admits_once(self): + def fail(): + raise OSError("injected storage failure before commit") + with self.assertRaises(OSError): + self.db.receive("capture-1", b"actual source", still(), fail) + self.reopen() + self.assertEqual(self.db.snapshot()["assets"], []) + self.assertEqual(self.db.snapshot()["requests"][0][1], "pending") + self.assertFalse(self.db.progress()["detail-a"]["captured"]) + self.assertEqual(self.db.receive("capture-1", b"actual source", still()), "admitted") + self.assertEqual(self.db.progress()["detail-a"]["unique_assets"], 1) + + def test_owned_process_killed_before_commit(self): + self.db.close() + marker = Path(self.tmp.name) / "transaction-entered" + proc = subprocess.Popen([sys.executable, __file__, "--crash-worker", str(self.path), str(marker)], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + deadline = time.monotonic() + 10 + while not marker.exists() and proc.poll() is None and time.monotonic() < deadline: + time.sleep(.02) + self.assertTrue(marker.exists(), "Owned child never reached precommit seam") + proc.kill() + proc.communicate(timeout=3) + self.assertEqual(proc.returncode, -signal.SIGKILL) + finally: + if proc.poll() is None: + proc.kill() + proc.communicate(timeout=3) + self.db = Ledger(self.path) + self.assertEqual(self.db.snapshot()["assets"], []) + self.assertEqual(self.db.snapshot()["requests"][0][1], "pending") + self.assertFalse(any(v["captured"] for v in self.db.progress().values())) + + def test_callback_idempotency_and_conflict_after_restart(self): + self.db.receive("capture-1", b"actual source", still()) + self.reopen() + before = self.db.snapshot() + self.assertEqual(self.db.receive("capture-1", b"actual source", still()), "duplicate_callback") + for payload, frame in [(b"different source", still()), (b"actual source", still(timestamp=10.8))]: + with self.assertRaises(Conflict): + self.db.receive("capture-1", payload, frame) + self.assertEqual(self.db.snapshot(), before) + + def test_other_current_requirement_does_not_inherit_requested_target(self): + # Actual assessment supports B even though preview/request was for A. + self.db.receive("capture-1", b"actual B detail", still(supports=(("detail-b", 1),))) + self.assertFalse(self.db.progress()["detail-a"]["captured"]) + self.assertTrue(self.db.progress()["detail-b"]["captured"]) + + def test_reset_preserves_details_and_invalidates_old_spatial_progress(self): + self.db.receive("capture-1", b"actual source", still()) + self.assertTrue(self.db.progress()["wall-a"]["captured"]) + original_assets = self.db.snapshot()["assets"] + self.db.set_epoch("epoch-b") + self.assertTrue(self.db.progress()["detail-a"]["captured"]) + self.assertFalse(self.db.progress()["wall-a"]["captured"]) + self.assertEqual(self.db.snapshot()["assets"], original_assets) + + def test_old_epoch_callback_and_limited_tracking_are_not_spatial_evidence(self): + self.db.set_epoch("epoch-b") + self.db.receive("capture-1", b"old epoch still", still()) + self.assertTrue(self.db.progress()["detail-a"]["captured"]) + self.assertFalse(self.db.progress()["wall-a"]["captured"]) + self.db.request("capture-2", "wall-a", "preview-b") + self.db.receive("capture-2", b"limited still", still(epoch="epoch-b", tracking="limited")) + self.assertFalse(self.db.progress()["wall-a"]["captured"]) + + def test_requirement_revision_and_unknown_target_do_not_reuse_support(self): + self.db.receive("capture-1", b"actual source", still(supports=(("detail-a", 1), ("future-detail", 1)))) + self.db.require("detail-a", 2, "detail") + self.db.require("future-detail", 1, "detail") + self.assertFalse(self.db.progress()["detail-a"]["captured"]) + self.assertFalse(self.db.progress()["future-detail"]["captured"]) + with self.assertRaises(Conflict): + self.db.require("detail-a", 1, "detail") + + def test_empty_assessment_does_not_complete_request(self): + self.db.receive("capture-1", b"unhelpful but sharp", still(supports=())) + self.assertFalse(any(v["captured"] for v in self.db.progress().values())) + + def test_server_acceptance_matches_exact_committed_hash_and_length(self): + data = b"actual source" + self.db.receive("capture-1", data, still()) + digest = hashlib.sha256(data).hexdigest() + for key, size in [("0" * 64, len(data)), (digest, len(data) + 1), (digest, True)]: + with self.assertRaises(Conflict): + self.db.acknowledge(key, size) + self.assertEqual(self.db.snapshot()["assets"][0][2], 0) + self.db.acknowledge(digest, len(data)) + self.db.acknowledge(digest, len(data)) + self.assertEqual(self.db.snapshot()["assets"][0][2], 1) + + def test_identical_asset_under_two_requests_has_one_unique_asset(self): + self.db.receive("capture-1", b"same bytes", still()) + self.db.request("capture-2", "detail-a", "preview-frame-200") + self.db.receive("capture-2", b"same bytes", still(frame_id="actual-frame-207", timestamp=20.7)) + self.assertEqual(len(self.db.snapshot()["assets"]), 1) + self.assertEqual(self.db.progress()["detail-a"]["unique_assets"], 1) + self.assertEqual(len(self.db.snapshot()["requests"]), 2) + + def test_single_inflight_capture_and_explicit_orphan_recovery(self): + with self.assertRaises(sqlite3.IntegrityError): + self.db.request("capture-2", "detail-b", "preview-frame-101") + self.assertEqual(self.db.recover_orphaned_requests(), 1) + self.db.request("capture-2", "detail-b", "preview-frame-200") + with self.assertRaises(Conflict): + self.db.receive("capture-1", b"late callback", still()) + self.assertEqual(self.db.snapshot()["assets"], []) + + def test_prior_epoch_cannot_resurrect_spatial_support(self): + self.db.receive("capture-1", b"actual source", still()) + self.db.set_epoch("epoch-b") + self.reopen() + with self.assertRaises(Conflict): + self.db.set_epoch("epoch-a") + self.assertFalse(self.db.progress()["wall-a"]["captured"]) + self.db.set_epoch("epoch-b") # Current-epoch assignment is idempotent. + + def test_unknown_future_epoch_does_not_gain_spatial_support_later(self): + self.db.receive("capture-1", b"unregistered future-epoch source", still(epoch="future-epoch")) + self.assertFalse(self.db.progress()["wall-a"]["captured"]) + self.db.set_epoch("future-epoch") + self.assertFalse(self.db.progress()["wall-a"]["captured"]) + self.assertTrue(self.db.progress()["detail-a"]["captured"]) + + def test_malformed_metadata_cannot_use_sqlite_affinity_to_match_task(self): + self.db.require("1", 1, "detail") + bad = [still(frame_id=["frame"]), still(timestamp=True), still(timestamp=float("nan")), + still(epoch=1), still(supports=((True, 1),)), still(supports=(("1", True),))] + before = self.db.snapshot() + for frame in bad: + with self.assertRaises(ValueError): + self.db.receive("capture-1", b"actual source", frame) + self.assertEqual(self.db.snapshot(), before) + for invalid in (True, 1, ["task"], " "): + with self.assertRaises(ValueError): + self.db.require(invalid, 1, "detail") + self.assertFalse(self.db.progress()["1"]["captured"]) + + def test_two_writers_cannot_regress_requirement_revision(self): + self.db.require("detail-a", 2, "detail") + self.db.receive("capture-1", b"revision2 evidence", still(supports=(("detail-a", 2),))) + ready, go, done = threading.Event(), threading.Event(), threading.Event() + failures = [] + def writer_b(): + other = None + try: + other = Ledger(self.path) + ready.set() + if not go.wait(3): + raise RuntimeError("Writer B was never released") + other.require("detail-a", 3, "detail") + except BaseException as error: + failures.append(repr(error)) + finally: + if other: + other.close() + done.set() + worker = threading.Thread(target=writer_b) + worker.start() + self.assertTrue(ready.wait(3)) + def before_insert(sql): + if sql.startswith("INSERT OR REPLACE INTO requirements"): + # A has already read rev2. Old separate autocommits allowed B to + # install rev3 here, then A overwrote it with rev2. BEGIN IMMEDIATE + # now holds the write reservation through the validated write. + go.set() + done.wait(.3) + self.db.db.set_trace_callback(before_insert) + try: + self.db.require("detail-a", 2, "detail") + finally: + self.db.db.set_trace_callback(None) + go.set() + worker.join(timeout=3) + self.assertFalse(worker.is_alive()) + self.assertEqual(failures, []) + self.assertEqual(self.db.progress()["detail-a"]["revision"], 3) + self.assertFalse(self.db.progress()["detail-a"]["captured"]) + + def test_progress_cannot_publish_uncommitted_admission(self): + def before_commit(): + with self.assertRaises(Conflict): + self.db.progress() + with self.assertRaises(Conflict): + self.db.snapshot() + self.db.receive("capture-1", b"actual source", still(), before_commit) + self.assertTrue(self.db.progress()["detail-a"]["captured"]) + + def test_snapshot_is_one_read_view_during_concurrent_epoch_and_revision_changes(self): + self.db.receive("capture-1", b"actual source", still()) + ready, go, done = threading.Event(), threading.Event(), threading.Event() + errors = [] + def change_state(): + other = None + try: + other = Ledger(self.path) + ready.set() + if not go.wait(3): + raise RuntimeError("Writer was not released") + other.set_epoch("epoch-b") + other.require("detail-a", 2, "detail") + except BaseException as e: + errors.append(repr(e)) + finally: + if other: + other.close() + done.set() + worker = threading.Thread(target=change_state) + worker.start() + self.assertTrue(ready.wait(3)) + def change_after_epoch_read(sql): + if sql.startswith("SELECT id,revision,kind FROM requirements"): + go.set() + done.wait(2) + self.db.db.set_trace_callback(change_after_epoch_read) + try: + old_view = self.db.snapshot() + finally: + self.db.db.set_trace_callback(None) + go.set() + worker.join(timeout=3) + self.assertFalse(worker.is_alive()) + self.assertEqual(errors, []) + self.assertEqual(old_view["epoch"], "epoch-a") + self.assertEqual(old_view["progress"]["detail-a"]["revision"], 1) + self.assertTrue(old_view["progress"]["wall-a"]["captured"]) + current = self.db.snapshot() + self.assertEqual(current["epoch"], "epoch-b") + self.assertEqual(current["progress"]["detail-a"]["revision"], 2) + self.assertFalse(current["progress"]["wall-a"]["captured"]) + + +if __name__ == "__main__": + if len(sys.argv) == 4 and sys.argv[1] == "--crash-worker": + ledger = Ledger(Path(sys.argv[2])) + def freeze_before_commit(): + Path(sys.argv[3]).write_text("inside uncommitted admission transaction") + signal.pause() + ledger.receive("capture-1", b"actual source", still(), freeze_before_commit) + raise SystemExit("The owned worker should be killed before commit") + suite = unittest.defaultTestLoader.loadTestsFromTestCase(AdmissionControls) + result = unittest.TextTestRunner(verbosity=2).run(suite) + receipt = {"tests_run": result.testsRun, "failures": len(result.failures), "errors": len(result.errors), + "passed": result.wasSuccessful(), "scope": "synthetic admission protocol and SQLite process-crash rollback, no camera/perception/physical accuracy", + "python": sys.version, "sqlite": sqlite3.sqlite_version} + Path(__file__).with_name("test-receipt.json").write_text(json.dumps(receipt, indent=2) + "\n") + raise SystemExit(0 if result.wasSuccessful() else 1) diff --git a/experiments/research-handoff/capture-admission/test_regressions.py b/experiments/research-handoff/capture-admission/test_regressions.py new file mode 100644 index 000000000..ac4053a19 --- /dev/null +++ b/experiments/research-handoff/capture-admission/test_regressions.py @@ -0,0 +1,274 @@ +"""Existing independent synthetic regression controls, packaged without review tooling.""" +from dataclasses import replace +import hashlib +import sqlite3 +import tempfile +import threading +import time +import unittest +from pathlib import Path + +from admission import Conflict, Ledger, ReturnedStill + +TEMP = None +DETAILS = {} + + +def frame(**changes): + return replace(ReturnedStill("actual-1", 1.2, "epoch-a", "normal", True, + (("detail", 1), ("wall", 2))), **changes) + + +class FixControls(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(dir=TEMP) + self.path = Path(self.tmp.name) / "ledger.sqlite" + self.db = Ledger(self.path) + self.db.set_epoch("epoch-a") + self.db.require("detail", 1, "detail") + self.db.require("wall", 2, "spatial") + self.db.request("capture-1", "wall", "preview-1") + + def tearDown(self): + self.db.close() + self.tmp.cleanup() + + def test_epoch_aba_reopen_and_late_old_epoch(self): + self.db.receive("capture-1", b"first", frame()) + self.assertTrue(self.db.progress()["wall"]["captured"]) + self.db.set_epoch("epoch-b") + self.db.close() + self.db = Ledger(self.path) + before = self.db.snapshot() + with self.assertRaises(Conflict): + self.db.set_epoch("epoch-a") + self.db.set_epoch("epoch-b") + self.assertEqual(before, self.db.snapshot()) + self.db.request("capture-2", "wall", "preview-2") + self.db.receive("capture-2", b"late old epoch", frame(frame_id="actual-2")) + after = self.db.snapshot() + self.assertTrue(after["progress"]["detail"]["captured"]) + self.assertFalse(after["progress"]["wall"]["captured"]) + self.assertEqual(after["progress"]["wall"]["spatial_epoch"], "epoch-b") + self.assertEqual(self.db.db.execute("SELECT requirement_id FROM support WHERE capture_id='capture-2'").fetchall(), [("detail",)]) + DETAILS["epoch_aba"] = {"after_reopen_and_old_epoch_attempt": before, + "late_old_epoch_callback": after} + + def test_unknown_future_epoch_and_retry_stay_ineligible(self): + future = frame(epoch="future-epoch") + self.db.receive("capture-1", b"future", future) + first = self.db.snapshot() + self.db.set_epoch("future-epoch") + self.db.close() + self.db = Ledger(self.path) + self.assertEqual(self.db.receive("capture-1", b"future", future), "duplicate_callback") + after = self.db.snapshot() + self.assertTrue(after["progress"]["detail"]["captured"]) + self.assertFalse(after["progress"]["wall"]["captured"]) + self.assertEqual(self.db.db.execute("SELECT requirement_id FROM support").fetchall(), [("detail",)]) + DETAILS["future_epoch"] = {"before_epoch_introduction": first, + "after_introduction_reopen_identical_retry": after} + + def test_actual_two_connection_revision_race(self): + self.db.receive("capture-1", b"revision 2", frame()) + ready, go, attempted, done = [threading.Event() for _ in range(4)] + failures, events, seam_checks = [], [], [] + def record(name): + events.append({"event": name, "monotonic": time.monotonic()}) + def writer_b(): + db = None + try: + db = Ledger(self.path) + def trace(sql): + if sql == "BEGIN IMMEDIATE": + record("B BEGIN IMMEDIATE attempted") + attempted.set() + db.db.set_trace_callback(trace) + ready.set() + if not go.wait(3): + raise RuntimeError("B not released") + db.require("wall", 3, "spatial") + record("B revision 3 committed") + except BaseException as e: + failures.append(repr(e)) + finally: + if db: + db.close() + done.set() + worker = threading.Thread(target=writer_b) + worker.start() + self.assertTrue(ready.wait(3)) + def at_a_write(sql): + if sql.startswith("INSERT OR REPLACE INTO requirements"): + record("A validated revision 2; write pending") + go.set() + saw_attempt = attempted.wait(2) + committed_while_a_held_write = done.wait(.15) + seam_checks.append({"b_attempted_begin": saw_attempt, + "b_completed_while_a_transaction_open": committed_while_a_held_write, + "a_in_transaction": self.db.db.in_transaction}) + self.db.db.set_trace_callback(at_a_write) + try: + self.db.require("wall", 2, "spatial") + record("A revision 2 committed") + finally: + self.db.db.set_trace_callback(None) + go.set() + worker.join(4) + self.assertFalse(worker.is_alive()) + self.assertEqual(failures, []) + self.assertEqual(seam_checks, [{"b_attempted_begin": True, + "b_completed_while_a_transaction_open": False, + "a_in_transaction": True}]) + after = self.db.progress() + self.assertEqual(after["wall"]["revision"], 3) + self.assertFalse(after["wall"]["captured"]) + with self.assertRaises(Conflict): + self.db.require("wall", 2, "spatial") + DETAILS["two_connection_revision_race"] = {"events": events, "seam_checks": seam_checks, "after": after} + + def test_runtime_types_reject_affinity_alias_and_malformed_shapes(self): + self.db.require("1", 1, "detail") + bad_changes = [ + {"frame_id": ["bad"]}, {"frame_id": True}, {"frame_id": " "}, + {"timestamp": True}, {"timestamp": False}, {"timestamp": "1.0"}, + {"timestamp": float("nan")}, {"timestamp": float("inf")}, {"timestamp": -1}, + {"epoch": 1}, {"epoch": {}}, {"epoch": "\t"}, + {"tracking": ["normal"]}, {"tracking": True}, {"tracking": "unknown"}, + {"usable": 1}, {"usable": "true"}, + {"supports": ((True, 1),)}, {"supports": (("1", True),)}, + {"supports": (("1", 1.0),)}, {"supports": (("1", 0),)}, + {"supports": ((" ", 1),)}, {"supports": ((["1"], 1),)}, + {"supports": [("1", 1)]}, {"supports": (["1", 1],)}, + {"supports": (("1",),)}, {"supports": (("1", 1, "extra"),)}, + {"supports": None}, + ] + before = self.db.snapshot() + cases = [] + for changes in bad_changes: + with self.subTest(changes=repr(changes)): + with self.assertRaises(ValueError): + self.db.receive("capture-1", b"still", frame(**changes)) + self.assertEqual(self.db.snapshot(), before) + cases.append(repr(changes)) + bad_api = [ + lambda: self.db.require(True, 1, "detail"), + lambda: self.db.require("1", True, "detail"), + lambda: self.db.require("x", 1, ["detail"]), + lambda: self.db.set_epoch(True), + lambda: self.db.request(True, "wall", "preview"), + lambda: self.db.request("new", True, "preview"), + lambda: self.db.request("new", "wall", {}), + lambda: self.db.receive(True, b"still", frame()), + lambda: self.db.acknowledge(True, 5), + ] + for i, call in enumerate(bad_api): + with self.subTest(api_case=i): + with self.assertRaises(ValueError): + call() + self.assertEqual(self.db.snapshot(), before) + self.assertFalse(self.db.progress()["1"]["captured"]) + DETAILS["runtime_types"] = {"returned_still_rejections": cases, "public_api_rejections": len(bad_api), "no_snapshot_mutations": True} + + def test_read_view_does_not_mix_concurrent_admission_and_ack(self): + ready, go, done = [threading.Event() for _ in range(3)] + errors, seam_checks = [], [] + payload = b"concurrent still" + def writer(): + db = None + try: + db = Ledger(self.path) + ready.set() + if not go.wait(3): + raise RuntimeError("writer not released") + db.receive("capture-1", payload, frame()) + db.acknowledge(hashlib.sha256(payload).hexdigest(), len(payload)) + except BaseException as e: + errors.append(repr(e)) + finally: + if db: + db.close() + done.set() + thread = threading.Thread(target=writer) + thread.start() + self.assertTrue(ready.wait(3)) + def after_first_epoch_read(sql): + if sql.startswith("SELECT id,revision,kind FROM requirements"): + go.set() + seam_checks.append({"writer_completed_during_reader_transaction": done.wait(3), + "reader_in_transaction": self.db.db.in_transaction}) + self.db.db.set_trace_callback(after_first_epoch_read) + try: + old_view = self.db.snapshot() + finally: + self.db.db.set_trace_callback(None) + go.set() + thread.join(4) + self.assertFalse(thread.is_alive()) + self.assertEqual(errors, []) + self.assertEqual(seam_checks, [{"writer_completed_during_reader_transaction": True, + "reader_in_transaction": True}]) + self.assertFalse(any(v["captured"] for v in old_view["progress"].values())) + self.assertEqual(old_view["requests"][0][1], "pending") + self.assertIsNone(old_view["requests"][0][3]) + self.assertEqual(old_view["assets"], []) + new_view = self.db.snapshot() + self.assertTrue(all(v["captured"] for v in new_view["progress"].values())) + self.assertEqual(new_view["requests"][0][1], "admitted") + self.assertEqual(new_view["assets"][0][2], 1) + DETAILS["read_view_admission_and_ack"] = {"seam_checks": seam_checks, "old_view": old_view, "new_view": new_view} + + def test_precommit_other_connection_view_and_rollback(self): + other = Ledger(self.path) + before = self.db.snapshot() + observed = {} + def before_commit(): + self.assertTrue(self.db.db.in_transaction) + for method in (self.db.progress, self.db.snapshot): + with self.assertRaises(Conflict): + method() + self.assertTrue(self.db.db.in_transaction) + observed["other_connection_during_write"] = other.snapshot() + self.assertEqual(observed["other_connection_during_write"], before) + raise OSError("independent rollback after failed publication attempts") + try: + with self.assertRaises(OSError): + self.db.receive("capture-1", b"rollback", frame(), before_commit) + self.assertFalse(self.db.db.in_transaction) + self.assertEqual(self.db.snapshot(), before) + self.assertEqual(other.snapshot(), before) + self.assertEqual(self.db.receive("capture-1", b"rollback", frame()), "admitted") + observed["other_connection_after_commit"] = other.snapshot() + self.assertTrue(observed["other_connection_after_commit"]["progress"]["wall"]["captured"]) + finally: + other.close() + DETAILS["precommit_publication_and_rollback"] = observed + + def test_failed_read_rolls_back_read_transaction(self): + self.db.receive("capture-1", b"saved", frame()) + before = self.db.snapshot() + denials = [] + def authorizer(action, table, column, database, source): + if action == sqlite3.SQLITE_READ and table == "assets": + denials.append({"table": table, "column": column, + "inside_transaction": self.db.db.in_transaction}) + return sqlite3.SQLITE_DENY + return sqlite3.SQLITE_OK + self.db.db.set_authorizer(authorizer) + try: + with self.assertRaises(sqlite3.DatabaseError) as caught: + self.db.snapshot() + error = str(caught.exception) + finally: + self.db.db.set_authorizer(None) + self.assertTrue(denials) + self.assertTrue(all(x["inside_transaction"] for x in denials)) + self.assertFalse(self.db.db.in_transaction) + self.assertEqual(self.db.snapshot(), before) + self.db.require("detail", 2, "detail") + self.assertFalse(self.db.progress()["detail"]["captured"]) + DETAILS["failed_read_cleanup"] = {"denials": denials, "error": error, "transaction_cleared": True, "subsequent_read_write_succeeded": True} + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/experiments/research-handoff/equipment-association/.gitignore b/experiments/research-handoff/equipment-association/.gitignore new file mode 100644 index 000000000..c18dd8d83 --- /dev/null +++ b/experiments/research-handoff/equipment-association/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/experiments/research-handoff/equipment-association/README.md b/experiments/research-handoff/equipment-association/README.md new file mode 100644 index 000000000..1d91654e5 --- /dev/null +++ b/experiments/research-handoff/equipment-association/README.md @@ -0,0 +1,15 @@ +# Equipment evidence association prototype + +A continuing tracker UUID does not establish that a label belongs to the intended enclosure. This standalone, in-memory event reducer preserves source observations, requires explicit contextual nomination and per-label review records, and makes superseded attachments historical after revisions, contradictions or resets. + +The published implementation is **fix v2**. It permits at most one ticket per candidate/label/candidate-revision, across all ticket states. An earlier version allowed a sibling ticket's late decision to overrule newer same-pair intent. The corrected version refuses that duplicate atomically; exact event replay remains idempotent. The same unresolved ticket can receive a fresh-revision review, and a new nomination/reset context permits a new ticket while preserving history. + +Run from the repository root with Python 3.10+; only the standard library is used: + +```sh +python3 -B experiments/research-handoff/equipment-association/test_association.py +``` + +Export validation on Python 3.13.7: **11 tests passed**, including four sibling-ticket regressions. The source and synthetic fixture are unchanged from reviewed v2; only the test module's stale preparation-time docstring was updated. The fixture's `cases` list describes the seven original scenarios; the test module includes the four later regressions. + +All photo references, hashes, labels and reviewer actions are invented. No image is loaded. Explicit review fields are software assertions, not verification of a person's inspection or physical equipment identity. Tracker confidence and label text cannot automatically attach evidence. The proposed sidecar is not integrated with the app, scene consumer or capture-admission prototype. Persistence, UI authorization, production concurrency and migration of historical duplicate tickets remain outside this experiment. diff --git a/experiments/research-handoff/equipment-association/association.py b/experiments/research-handoff/equipment-association/association.py new file mode 100644 index 000000000..58eb220a0 --- /dev/null +++ b/experiments/research-handoff/equipment-association/association.py @@ -0,0 +1,235 @@ +"""Prepared evidence-association reducer; software checks do not prove physical identity.""" +from __future__ import annotations +import copy +import hashlib +import json +import math +import re +import threading + + +class Conflict(ValueError): + pass + + +def require(ok, message): + if not ok: + raise Conflict(message) + + +def text(value): + return isinstance(value, str) and bool(value.strip()) + + +def digest(value): + return isinstance(value, str) and re.fullmatch(r"[0-9a-f]{64}", value) is not None + + +def canonical(value): + return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False) + + +def scope_key(scope): + require(isinstance(scope, dict) and set(scope) == {"session_id", "epoch_id", "sequence_id"}, "scope fields") + require(all(text(v) for v in scope.values()), "scope identifiers") + return canonical(scope) + + +def photo(region): + require(isinstance(region, dict) and set(region) == { + "frame_id", "asset_ref", "asset_sha256", "width", "height", "xywh", "scope" + }, "photo region fields") + require(text(region["frame_id"]) and text(region["asset_ref"]) and digest(region["asset_sha256"]), "photo reference/hash") + require(all(type(region[k]) is int and region[k] > 0 for k in ("width", "height")), "photo dimensions") + box = region["xywh"] + require(isinstance(box, list) and len(box) == 4 and all( + type(v) in (int, float) and math.isfinite(v) for v in box), "finite photo region") + x, y, w, h = box + require(x >= 0 and y >= 0 and w > 0 and h > 0 and x+w <= region["width"] and y+h <= region["height"], "photo region bounds") + scope_key(region["scope"]) + return region + + +class Associations: + """One process-local actor; persist events/state atomically in a real app adapter.""" + def __init__(self, scope): + key = scope_key(scope) + self._lock = threading.RLock() + self._state = {"revision": 0, "scope": copy.deepcopy(scope), "seen_scopes": [key], + "candidates": {}, "labels": {}, "tracks": {}, "nominations": {}, + "tickets": {}, "reviews": {}, "attachments": {}, "events": {}} + + def apply(self, event): + """Apply one event atomically; duplicate exact IDs return the original result.""" + frozen = json.loads(canonical(event)) + require(isinstance(frozen, dict) and set(frozen) == {"id", "type", "data"}, "event fields") + require(text(frozen["id"]) and text(frozen["type"]) and isinstance(frozen["data"], dict), "event identity/data") + encoded = canonical(frozen) + with self._lock: + old = self._state["events"].get(frozen["id"]) + if old: + require(old["event"] == frozen, "event ID reused with different content") + return copy.deepcopy(old["result"]) + state = copy.deepcopy(self._state) + result = self._reduce(state, frozen) + state["revision"] += 1 + result["snapshot_revision"] = state["revision"] + state["events"][frozen["id"]] = {"event": frozen, "sha256": hashlib.sha256(encoded.encode()).hexdigest(), "result": result} + self._state = state + return copy.deepcopy(result) + + @staticmethod + def _reduce(s, e): + kind, d, eid = e["type"], e["data"], e["id"] + def fields(*names): + require(set(d) == set(names), "unexpected/missing event fields") + def known_photo(value): + p = photo(value) + require(scope_key(p["scope"]) in s["seen_scopes"], "unregistered photo scope") + return p + def current_photo(value): + p = known_photo(value) + require(p["scope"] == s["scope"], "context photo is not in current scope") + return p + def candidate(cid, revision=None): + require(cid in s["candidates"], "unknown equipment candidate") + c = s["candidates"][cid] + if revision is not None: + require(type(revision) is int and revision == c["revision"], "stale candidate revision") + return c + def invalidate(cid, reason): + for ticket in s["tickets"].values(): + if ticket["candidate_id"] == cid and ticket["status"] in {"needs_context", "needs_review", "unresolved"}: + ticket.update(status="stale", reason=reason, revision=ticket["revision"]+1) + + if kind == "create_candidate": + fields("candidate_id", "description") + require(text(d["candidate_id"]) and text(d["description"]), "candidate description") + require(d["candidate_id"] not in s["candidates"], "candidate ID already exists") + s["candidates"][d["candidate_id"]] = {"id": d["candidate_id"], "description": d["description"], "revision": 0, "status": "needs_context", "nomination_id": None, "contradictions": []} + return {"status": "needs_context", "reason": "context_photo_required"} + if kind in {"record_label", "record_track"}: + if kind == "record_label": + fields("observation_id", "text", "producer", "raw_result_ref", "raw_result_sha256", "photo") + require(isinstance(d["text"], str) and text(d["producer"]) and text(d["raw_result_ref"]) and digest(d["raw_result_sha256"]), "label source fields") + table = s["labels"] + else: + fields("observation_id", "tracker_uuid", "confidence", "photo") + require(text(d["tracker_uuid"]) and type(d["confidence"]) in (int,float) and math.isfinite(d["confidence"]) and 0 <= d["confidence"] <= 1, "tracker diagnostics") + table = s["tracks"] + require(text(d["observation_id"]) and d["observation_id"] not in table, "observation ID reused") + known_photo(d["photo"]) + table[d["observation_id"]] = copy.deepcopy(d) + return {"status": "source_saved", "reason": "no_equipment_attachment_inferred"} + if kind == "nominate_context": + fields("candidate_id", "expected_candidate_revision", "actor", "reason", "photo", "alternatives", "considered_contradictions") + c = candidate(d["candidate_id"], d["expected_candidate_revision"]) + require(text(d["actor"]) and text(d["reason"]), "explicit context reviewer required") + current_photo(d["photo"]) + require(isinstance(d["alternatives"], list) and len(set(d["alternatives"])) == len(d["alternatives"]) and all(x in s["candidates"] and x != c["id"] for x in d["alternatives"]), "inspectable alternatives") + require(isinstance(d["considered_contradictions"], list) and sorted(d["considered_contradictions"]) == sorted(c["contradictions"]), "explicitly consider outstanding contradictions") + invalidate(c["id"], "context_changed") + c.update(revision=c["revision"]+1, status="context_nominated", nomination_id=eid, contradictions=[]) + s["nominations"][eid] = {**copy.deepcopy(d), "candidate_revision": c["revision"], "scope": copy.deepcopy(s["scope"])} + return {"status": "context_nominated", "candidate_revision": c["revision"], "nomination_id": eid, "reason": "label_specific_review_still_required"} + if kind == "request_attachment": + fields("ticket_id", "candidate_id", "label_id", "expected_candidate_revision") + c = candidate(d["candidate_id"], d["expected_candidate_revision"]) + require(text(d["ticket_id"]) and d["ticket_id"] not in s["tickets"] and d["label_id"] in s["labels"], "new ticket and saved label required") + # One authority per label/candidate/context; sibling tickets must not + # re-admit an older decision after this pair was resolved elsewhere. + require(not any(t["candidate_id"] == c["id"] and t["label_id"] == d["label_id"] + and t["candidate_revision"] == c["revision"] + for t in s["tickets"].values()), + "ticket already exists for candidate/label/context revision") + status = "needs_review" if c["status"] == "context_nominated" else "needs_context" + ticket = {"id": d["ticket_id"], "candidate_id": c["id"], "label_id": d["label_id"], "candidate_revision": c["revision"], "nomination_id": c["nomination_id"], "scope": copy.deepcopy(s["scope"]), "revision": 1, "status": status, "reason": "review_label_and_context" if status == "needs_review" else "context_photo_required"} + s["tickets"][ticket["id"]] = ticket + return copy.deepcopy(ticket) + if kind == "review_attachment": + fields("ticket_id", "expected_ticket_revision", "expected_candidate_revision", "decision", "actor", "reason", "photo") + require(d["ticket_id"] in s["tickets"] and d["decision"] in {"attach", "unresolved", "reject"} and text(d["actor"]) and text(d["reason"]), "explicit label review required") + known_photo(d["photo"]) + ticket = s["tickets"][d["ticket_id"]];c = candidate(ticket["candidate_id"]) + fresh = (type(d["expected_ticket_revision"]) is int and type(d["expected_candidate_revision"]) is int + and d["expected_ticket_revision"] == ticket["revision"] and d["expected_candidate_revision"] == c["revision"] + and ticket["candidate_revision"] == c["revision"] and ticket["scope"] == s["scope"] + and d["photo"]["scope"] == s["scope"] and ticket["nomination_id"] == c["nomination_id"] + and c["status"] == "context_nominated" and ticket["status"] in {"needs_review", "unresolved"}) + outcome = {"status": "stale_review", "reason": "context_scope_or_review_revision_changed"} + conflicts = set() + if fresh and d["decision"] == "attach": + for link in s["attachments"].values(): + other = candidate(link["candidate_id"]) + if (link["label_id"] == ticket["label_id"] and other["id"] != c["id"] + and other["status"] == "context_nominated" and other["revision"] == link["candidate_revision"] + and link["scope"] == s["scope"]): + conflicts.add(other["id"]) + if conflicts: + # A later incompatible explicit claim does not silently replace the earlier one. + # Both require context review; the original observation and decisions survive. + for cid in conflicts | {c["id"]}: + disputed = candidate(cid) + disputed.update(revision=disputed["revision"]+1, status="contradicted") + disputed["contradictions"].append(eid) + invalidate(cid, "conflicting_current_assignment") + outcome = {"status": "unresolved", "reason": "conflicting_current_assignment"} + s["reviews"][eid] = {**copy.deepcopy(d), "outcome": copy.deepcopy(outcome)} + return outcome + if fresh: + status = {"attach": "attached", "unresolved": "unresolved", "reject": "rejected"}[d["decision"]] + ticket.update(status=status, reason={"attach": "explicit_contextual_review", "unresolved": "association_unresolved", "reject": "review_rejected"}[d["decision"]], revision=ticket["revision"]+1) + outcome = {"status": status, "reason": ticket["reason"], "ticket_revision": ticket["revision"]} + if status == "attached": + s["attachments"][eid] = {"id": eid, "review_id": eid, "ticket_id": ticket["id"], "candidate_id": c["id"], "label_id": ticket["label_id"], "candidate_revision": c["revision"], "nomination_id": c["nomination_id"], "scope": copy.deepcopy(s["scope"])} + s["reviews"][eid] = {**copy.deepcopy(d), "outcome": copy.deepcopy(outcome)} + return outcome + if kind == "contradict": + fields("candidate_id", "expected_candidate_revision", "actor", "reason", "photo") + c = candidate(d["candidate_id"], d["expected_candidate_revision"]) + require(text(d["actor"]) and text(d["reason"]), "contradiction source required") + known_photo(d["photo"]) + c.update(revision=c["revision"]+1, status="contradicted") + c["contradictions"].append(eid);invalidate(c["id"], "association_contradicted") + return {"status": "contradicted", "reason": "context_review_required", "candidate_revision": c["revision"]} + if kind == "reset_scope": + fields("scope", "reason") + key = scope_key(d["scope"]);require(text(d["reason"]) and key not in s["seen_scopes"], "fresh reset scope and reason required") + new = d["scope"];old = s["scope"];seen = [json.loads(x) for x in s["seen_scopes"]] + if new["session_id"] != old["session_id"]: + require(not any(x["session_id"] == new["session_id"] for x in seen), "session ID cannot be resurrected") + elif new["epoch_id"] != old["epoch_id"]: + require(not any(x["session_id"] == new["session_id"] and x["epoch_id"] == new["epoch_id"] for x in seen), "coordinate epoch cannot be resurrected") + s["seen_scopes"].append(key);s["scope"] = copy.deepcopy(d["scope"]) + for c in s["candidates"].values(): + c.update(revision=c["revision"]+1, status="needs_context", nomination_id=None) + invalidate(c["id"], "scope_changed") + return {"status": "needs_context", "reason": "fresh_context_after_reset_required"} + raise Conflict("unsupported event; tracker continuity never attaches labels") + + def snapshot(self): + with self._lock: + s = copy.deepcopy(self._state) + for attachment in s["attachments"].values(): + c = s["candidates"][attachment["candidate_id"]] + current = c["status"] == "context_nominated" and c["revision"] == attachment["candidate_revision"] and s["scope"] == attachment["scope"] + attachment.update(current=current, status="reviewed_current" if current else "historical_only") + for ticket in s["tickets"].values(): + c = s["candidates"][ticket["candidate_id"]] + if ticket["status"] == "attached" and (ticket["scope"] != s["scope"] or ticket["candidate_revision"] != c["revision"]): + ticket.update(status="historical_only", reason="current_context_requires_review") + return s + + def sidecar(self, scene_sha256, expected_snapshot_revision): + """Proposed C1 adjunct only. No C1 object/admission/physical confidence emitted.""" + require(digest(scene_sha256), "exact C1 scene byte hash required") + with self._lock: + require(type(expected_snapshot_revision) is int and expected_snapshot_revision == self._state["revision"], "stale export snapshot") + s = self.snapshot() + return {"schema": "equipment-association-sidecar/1", "scene_sha256": scene_sha256, + "snapshot_revision": s["revision"], "scope": s["scope"], + "coordinate_contract": "Original encoded photo top-left pixel-cell xywh; caller must preserve camera/image transform provenance", + "candidates": s["candidates"], "label_observations": s["labels"], "track_observations": s["tracks"], + "nominations": s["nominations"], "review_tickets": s["tickets"], "reviews": s["reviews"], + "associations": s["attachments"], "events": s["events"], + "not_established": ["physical equipment identity", "photo/exposure provenance", "OCR correctness", "measurement uncertainty", "C1 ingestion compatibility", "capture-admission completion"]} diff --git a/experiments/research-handoff/equipment-association/fixtures.json b/experiments/research-handoff/equipment-association/fixtures.json new file mode 100644 index 000000000..b45226d0d --- /dev/null +++ b/experiments/research-handoff/equipment-association/fixtures.json @@ -0,0 +1,21 @@ +{ + "scope": {"session_id": "session-1", "epoch_id": "epoch-1", "sequence_id": "sequence-1"}, + "reset_scope": {"session_id": "session-1", "epoch_id": "epoch-1", "sequence_id": "sequence-2"}, + "notice": "Synthetic symbolic asset references/hashes only; no genuine photo, OCR or physical identity evidence. Candidate A/B names describe test intentions, not an inference made by the module.", + "photos": { + "context_a": {"frame_id": "frame-context-a", "asset_ref": "synthetic/context-a.jpg", "asset_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "width": 640, "height": 480, "xywh": [100, 80, 180, 250], "scope": {"session_id": "session-1", "epoch_id": "epoch-1", "sequence_id": "sequence-1"}}, + "context_b": {"frame_id": "frame-context-b", "asset_ref": "synthetic/context-b.jpg", "asset_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "width": 640, "height": 480, "xywh": [330, 80, 180, 250], "scope": {"session_id": "session-1", "epoch_id": "epoch-1", "sequence_id": "sequence-1"}}, + "label_a": {"frame_id": "frame-label-a", "asset_ref": "synthetic/label-a.jpg", "asset_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "width": 640, "height": 480, "xywh": [120, 130, 100, 40], "scope": {"session_id": "session-1", "epoch_id": "epoch-1", "sequence_id": "sequence-1"}}, + "label_b": {"frame_id": "frame-label-b", "asset_ref": "synthetic/label-b.jpg", "asset_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", "width": 640, "height": 480, "xywh": [350, 130, 100, 40], "scope": {"session_id": "session-1", "epoch_id": "epoch-1", "sequence_id": "sequence-1"}}, + "new_context_a": {"frame_id": "frame-new-context-a", "asset_ref": "synthetic/new-context-a.jpg", "asset_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", "width": 640, "height": 480, "xywh": [110, 90, 180, 250], "scope": {"session_id": "session-1", "epoch_id": "epoch-1", "sequence_id": "sequence-2"}} + }, + "cases": [ + {"id": "same_uuid_wrong_target", "expected": "Two high-confidence samples with the same UUID cannot attach a label; explicit unresolved review retains it without a link."}, + {"id": "explicit_review_and_immutable_source", "expected": "Inspectably reviewed per-label attachment is current; caller mutation cannot change saved source data."}, + {"id": "reset_and_late_label", "expected": "Old pending review is stale; late old-scope label remains saved; fresh context/new review is required."}, + {"id": "contradiction_preserves_history", "expected": "Current link becomes historical_only, pending ticket becomes stale, originals remain; contradiction acknowledgement is explicit. A competing explicit current assignment of the same label also makes both candidates disputed without silently replacing history."}, + {"id": "candidate_revision_race", "expected": "Late result for superseded nomination is recorded stale without current attachment."}, + {"id": "review_ticket_revision_race", "expected": "An old attach decision cannot overwrite a newer unresolved/current decision."}, + {"id": "idempotency_and_export_revision", "expected": "Exact event replay is idempotent, changed replay conflicts, old snapshot export and reused scope fail."} + ] +} diff --git a/experiments/research-handoff/equipment-association/test_association.py b/experiments/research-handoff/equipment-association/test_association.py new file mode 100644 index 000000000..61127f8a6 --- /dev/null +++ b/experiments/research-handoff/equipment-association/test_association.py @@ -0,0 +1,182 @@ +"""Eleven synthetic v2 transition cases; no real media or model inputs.""" +import copy +import importlib.util +import json +from pathlib import Path +import sys +import unittest + +ROOT=Path(__file__).resolve().parent +spec=importlib.util.spec_from_file_location('equipment_association_prepared',ROOT/'association.py') +module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module) +Associations,Conflict=module.Associations,module.Conflict +F=json.loads((ROOT/'fixtures.json').read_text()) + +class TransitionCases(unittest.TestCase): + def setUp(self): + self.a=Associations(F['scope']);self.serial=0 + for cid in ('A','B'):self.send('create_candidate',candidate_id=cid,description='Synthetic enclosure '+cid) + def send(self,kind,**data): + self.serial+=1 + return self.a.apply({'id':f'event-{self.serial}','type':kind,'data':data}) + def photo(self,name):return copy.deepcopy(F['photos'][name]) + def revision(self):return self.a.snapshot()['candidates']['A']['revision'] + def label(self,oid='label-a',which='label_a'): + return self.send('record_label',observation_id=oid,text='Original label '+oid,producer='synthetic-extractor',raw_result_ref='synthetic/'+oid+'.json',raw_result_sha256='f'*64,photo=self.photo(which)) + def nominate(self,which='context_a',considered=()): + return self.send('nominate_context',candidate_id='A',expected_candidate_revision=self.revision(),actor='reviewer-1',reason='Inspect this contextual photo; no identity probability asserted',photo=self.photo(which),alternatives=['B'],considered_contradictions=list(considered)) + def ticket(self,tid='ticket-a',label='label-a'): + return self.send('request_attachment',ticket_id=tid,candidate_id='A',label_id=label,expected_candidate_revision=self.revision()) + def review(self,ticket,decision='attach',which='context_a'): + return self.send('review_attachment',ticket_id=ticket['id'],expected_ticket_revision=ticket['revision'],expected_candidate_revision=ticket['candidate_revision'],decision=decision,actor='reviewer-1',reason='Explicit inspection of original label and contextual enclosure evidence',photo=self.photo(which)) + def current(self):return [x for x in self.a.snapshot()['attachments'].values() if x['current']] + + def test_same_uuid_wrong_target(self): + self.nominate();self.label('label-b','label_b') + for i,which in enumerate(('context_a','context_b')): + self.send('record_track',observation_id=f'track-{i}',tracker_uuid='unchanged-uuid',confidence=.95,photo=self.photo(which)) + ticket=self.ticket(label='label-b') + self.assertEqual(ticket['status'],'needs_review');self.assertEqual(self.current(),[]) + with self.assertRaises(Conflict):self.send('attach_from_tracker',ticket_id=ticket['id'],tracker_uuid='unchanged-uuid',confidence=.95) + self.assertEqual(self.review(ticket,'unresolved','context_b')['status'],'unresolved') + self.assertEqual(self.current(),[]);self.assertIn('label-b',self.a.snapshot()['labels']) + + def test_explicit_review_and_immutable_source(self): + event={'id':'original-label','type':'record_label','data':{'observation_id':'label-a','text':'SERIAL ORIGINAL','producer':'synthetic','raw_result_ref':'synthetic/original.json','raw_result_sha256':'f'*64,'photo':self.photo('label_a')}} + self.a.apply(event);event['data']['text']='MUTATED';event['data']['photo']['xywh'][0]=999 + self.nominate();self.assertEqual(self.review(self.ticket())['status'],'attached') + view=self.a.snapshot();self.assertEqual(view['labels']['label-a']['text'],'SERIAL ORIGINAL') + self.assertEqual(len(self.current()),1) + view['labels']['label-a']['text']='VIEW MUTATION' + self.assertEqual(self.a.snapshot()['labels']['label-a']['text'],'SERIAL ORIGINAL') + sidecar=self.a.sidecar('0'*64,self.a.snapshot()['revision']) + self.assertEqual(sidecar['schema'],'equipment-association-sidecar/1') + self.assertIn('physical equipment identity',sidecar['not_established']);self.assertNotIn('supports',sidecar) + + def test_reset_and_late_label(self): + self.label();self.nominate();old=self.ticket() + self.send('reset_scope',scope=copy.deepcopy(F['reset_scope']),reason='Tracking reset') + self.label('late-label','label_b') + self.assertEqual(self.review(old)['status'],'stale_review') + self.assertIn('late-label',self.a.snapshot()['labels']);self.assertEqual(self.current(),[]) + self.nominate('new_context_a') + new=self.ticket('new-ticket','late-label') + self.assertEqual(self.review(new,which='new_context_a')['status'],'attached') + self.assertEqual(len(self.current()),1) + + def test_contradiction_preserves_history(self): + self.label();self.label('label-b','label_b');self.nominate();self.review(self.ticket()) + self.ticket('pending-b','label-b') + original=copy.deepcopy(self.a.snapshot()['labels']) + self.send('contradict',candidate_id='A',expected_candidate_revision=self.revision(),actor='reviewer-2',reason='Context suggests a different enclosure',photo=self.photo('context_b')) + snapshot=self.a.snapshot();cid=snapshot['candidates']['A']['contradictions'][0] + self.assertEqual(self.current(),[]);self.assertEqual(snapshot['labels'],original) + self.assertEqual(next(iter(snapshot['attachments'].values()))['status'],'historical_only') + self.assertEqual(snapshot['tickets']['pending-b']['status'],'stale') + with self.assertRaises(Conflict):self.nominate() + self.nominate(considered=[cid]) + self.assertEqual(self.current(),[],'New nomination does not automatically restore historical links') + self.review(self.ticket('reattach-a')) + self.send('nominate_context',candidate_id='B',expected_candidate_revision=0,actor='reviewer-2',reason='Competing explicit context claim',photo=self.photo('context_b'),alternatives=['A'],considered_contradictions=[]) + competing=self.send('request_attachment',ticket_id='competing-b',candidate_id='B',label_id='label-a',expected_candidate_revision=1) + result=self.review(competing,which='context_b') + self.assertEqual(result['reason'],'conflicting_current_assignment') + self.assertEqual(self.current(),[]) + self.assertTrue(all(c['status']=='contradicted' for c in self.a.snapshot()['candidates'].values())) + self.assertEqual(self.a.snapshot()['labels'],original) + + def test_candidate_revision_race(self): + self.label();self.nominate();old=self.ticket() + self.nominate('context_b') + self.assertEqual(self.review(old)['status'],'stale_review') + self.assertEqual(self.current(),[]) + self.assertEqual(self.a.snapshot()['candidates']['A']['status'],'context_nominated') + + def test_review_ticket_revision_race(self): + self.label();self.nominate();old=self.ticket() + self.assertEqual(self.review(old,'unresolved')['status'],'unresolved') + self.assertEqual(self.review(old,'attach')['status'],'stale_review');self.assertEqual(self.current(),[]) + current=self.a.snapshot()['tickets']['ticket-a'] + self.assertEqual(self.review(current,'attach')['status'],'attached') + self.assertEqual(self.review(old,'unresolved')['status'],'stale_review') + self.assertEqual(self.a.snapshot()['tickets']['ticket-a']['status'],'attached') + self.assertEqual(len(self.current()),1) + + def test_idempotency_and_export_revision(self): + event={'id':'sample','type':'record_track','data':{'observation_id':'sample','tracker_uuid':'uuid','confidence':.99,'photo':self.photo('context_a')}} + old_revision=self.a.snapshot()['revision'];first=self.a.apply(event) + self.assertEqual(self.a.apply(copy.deepcopy(event)),first) + event['data']['confidence']=.8 + with self.assertRaises(Conflict):self.a.apply(event) + with self.assertRaises(Conflict):self.a.sidecar('0'*64,old_revision) + self.send('reset_scope',scope=copy.deepcopy(F['reset_scope']),reason='Reset') + with self.assertRaises(Conflict):self.send('reset_scope',scope=copy.deepcopy(F['scope']),reason='Old sequence must not return') + unknown=self.photo('label_a');unknown['scope']['epoch_id']='future' + with self.assertRaises(Conflict):self.send('record_label',observation_id='future',text='saved?',producer='synthetic',raw_result_ref='synthetic/future.json',raw_result_sha256='f'*64,photo=unknown) + self.assertNotIn('future',self.a.snapshot()['labels']) + + def assert_sibling_refused_atomically(self, ticket_id): + before = self.a.snapshot() + with self.assertRaises(Conflict): + self.ticket(ticket_id) + self.assertEqual(self.a.snapshot(), before) + + def test_sibling_ticket_rejection_order(self): + self.label(); self.nominate(); first = self.ticket() + # A queued second form cannot create independent authority before a decision. + self.assert_sibling_refused_atomically('sibling-before-reject') + self.assertEqual(self.review(first, 'reject')['status'], 'rejected') + # Nor can a later request bypass this revision's rejection. + self.assert_sibling_refused_atomically('sibling-after-reject') + self.assertEqual(self.current(), []) + self.nominate('context_b') + fresh = self.ticket('fresh-after-rejected-nomination') + self.assertEqual(self.review(fresh, which='context_b')['status'], 'attached') + self.assertEqual(len(self.current()), 1) + self.assertEqual(self.a.snapshot()['tickets']['ticket-a']['status'], 'rejected') + + def test_sibling_ticket_unresolved_order(self): + self.label(); self.nominate(); first = self.ticket() + self.assert_sibling_refused_atomically('sibling-before-unresolved') + self.assertEqual(self.review(first, 'unresolved')['status'], 'unresolved') + self.assert_sibling_refused_atomically('sibling-after-unresolved') + self.assertEqual(self.current(), []) + current = self.a.snapshot()['tickets']['ticket-a'] + self.assertEqual(self.review(current)['status'], 'attached') + self.assertEqual(len(self.current()), 1) + + def test_attached_ticket_replay_and_new_nomination(self): + self.label(); self.nominate() + event = {'id': 'exact-ticket-request', 'type': 'request_attachment', + 'data': {'ticket_id': 'ticket-a', 'candidate_id': 'A', + 'label_id': 'label-a', 'expected_candidate_revision': self.revision()}} + original_result = self.a.apply(event) + self.assertEqual(self.review(original_result)['status'], 'attached') + before = self.a.snapshot() + self.assertEqual(self.a.apply(copy.deepcopy(event)), original_result) + self.assertEqual(self.a.snapshot(), before, 'Exact replay does not reapply or advance state') + self.assert_sibling_refused_atomically('sibling-after-attached') + self.nominate('context_b') + self.assertEqual(self.current(), []) + fresh = self.ticket('fresh-after-attached-nomination') + self.assertEqual(self.review(fresh, which='context_b')['status'], 'attached') + self.assertEqual(len(self.current()), 1) + links = list(self.a.snapshot()['attachments'].values()) + self.assertEqual(sum(link['status'] == 'historical_only' for link in links), 1) + + def test_same_pair_after_scope_reset(self): + self.label(); self.nominate(); self.review(self.ticket()) + source = copy.deepcopy(self.a.snapshot()['labels']['label-a']) + self.send('reset_scope', scope=copy.deepcopy(F['reset_scope']), reason='New capture scope') + provisional = self.ticket('reset-needs-context') + self.assertEqual(provisional['status'], 'needs_context') + self.nominate('new_context_a') + self.assertEqual(self.a.snapshot()['tickets']['reset-needs-context']['status'], 'stale') + fresh = self.ticket('reset-fresh-context') + self.assertEqual(self.review(fresh, which='new_context_a')['status'], 'attached') + self.assertEqual(len(self.current()), 1) + self.assertEqual(self.a.snapshot()['labels']['label-a'], source) + links = list(self.a.snapshot()['attachments'].values()) + self.assertEqual(sum(link['status'] == 'historical_only' for link in links), 1) + +if __name__=='__main__':unittest.main(verbosity=2) diff --git a/experiments/research-handoff/native-proposals/.gitattributes b/experiments/research-handoff/native-proposals/.gitattributes new file mode 100644 index 000000000..9dff32d1e --- /dev/null +++ b/experiments/research-handoff/native-proposals/.gitattributes @@ -0,0 +1,2 @@ +# Unified patches represent unchanged blank context lines with a single space. +*.patch whitespace=-blank-at-eol diff --git a/experiments/research-handoff/native-proposals/README.md b/experiments/research-handoff/native-proposals/README.md new file mode 100644 index 000000000..9f1589daa --- /dev/null +++ b/experiments/research-handoff/native-proposals/README.md @@ -0,0 +1,27 @@ +# Native patch proposals + +Both patches target exact commit [`a39d0a503412d688dd151f628cdeebc00f8221b6`](https://github.com/SamGu-NRX/house-scanning/commit/a39d0a503412d688dd151f628cdeebc00f8221b6). They are review artifacts, not changes applied to this branch's app. Patch labels are repository-relative; hashes are recorded in [patch-identities.json](patch-identities.json). + +| Patch | Target | Status | +| --- | --- | --- | +| [scan-engine-task-guards.patch](scan-engine-task-guards.patch) | `ios/HouseScan/Runtime/ScanEngine.swift` | Narrow native host cases executed; full iOS integration untested | +| [ground-replay-helper-unrun.patch](ground-replay-helper-unrun.patch) | `ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift` | Prepared only; **UNRUN and unintegrated** | + +The ScanEngine patch refuses obsolete queued keyframe/close-up work before storage starts, and rechecks generation/phase after the thumbnail await before updating current UI/counts. It moves the existing per-generation pending-save decrement into a Task-exit `defer`, so success, failure and early returns drain the captured generation's entry. It preserves this baseline's store epoch and dictionary-based pending counts. + +One Swift 6 host build and one run exercised nine paired cases: **18 observations matched**. Four baseline defect cases reproduced obsolete storage calls or stale post-thumbnail updates; all nine patched cases met their assertions, including five existing-behavior/drain controls. The executable used eight source-bound current/patched method bodies and four explicit completion-signaling overlays, with controlled storage, UI and framework dependencies. This is actual-method host evidence, not a full app/store/JPEG/OCR test. It does not fix producer-frame world provenance, already-running fixed-name still writes, durable capture inventory, export/response correlation or same-generation retake identity. No old scalar-counter patch should be transplanted over the current dictionary design. + +The ground helper proposes synchronous recomputation from a caller-qualified camera ledger in original acceptance order, including observations whose original delta was zero. It retains latent skip flags and marked-end coordinates, discards photographic row state, replays existing geometry methods, and advances revision from the prior map. Unchanged samples and meter-y-only updates preserve current evidence/revision. Changed horizontal geometry, nonfinite heights or a nonzero residual cell shift are refused without mutation. + +**The ground helper is not a complete geometry fix.** Ordinary anchor refinement can leave a residual shift and prevent its use. Carrying that residual into freshly projected evidence, or dropping it while retaining rounded skip cells, would mix coordinate histories. Caller ledger admission, world/session provenance, refusal handling, coherent UI publication and export snapshot integration remain unresolved. This helper does not implement them. Nine source-prepared controls cover fresh-map equivalence, useful zero-delta observations, order dependence, latent skips/bounds and revision collisions; none was compiled or run. It makes no physical-accuracy, occlusion or phone-performance claim. + +To check applicability later, use a separate checkout already at the exact baseline and inspect the output of `git rev-parse HEAD` first. These commands only check whether the patches apply; they do not apply them: + +```sh +git rev-parse HEAD +# Expected: a39d0a503412d688dd151f628cdeebc00f8221b6 +git apply --check /path/to/native-proposals/scan-engine-task-guards.patch +git apply --check /path/to/native-proposals/ground-replay-helper-unrun.patch +``` + +Applicability against another commit requires a fresh source review. No apply check, application, app build or test rerun was performed while packaging this handoff. The export check establishes byte identity and relative labels only. diff --git a/experiments/research-handoff/native-proposals/ground-replay-helper-unrun.patch b/experiments/research-handoff/native-proposals/ground-replay-helper-unrun.patch new file mode 100644 index 000000000..208fdbbd0 --- /dev/null +++ b/experiments/research-handoff/native-proposals/ground-replay-helper-unrun.patch @@ -0,0 +1,59 @@ +--- a/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift ++++ b/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift +@@ -310,6 +310,56 @@ + revision += 1 + } + ++ /// A narrow ground-replay boundary; does not admit/qualify observations or repair anchor shifts. ++ public enum GroundReplayError: Error { ++ case invalidHeight ++ case horizontalFrameChanged ++ case unresolvedCellShift ++ } ++ ++ /// Re-evaluates all caller-qualified accepted cameras in their original acceptance order. ++ /// The caller owns source-ID deduplication, successful storage, tracking and generation checks. ++ /// Include zero-delta observations. Never pass speculative planner cameras or all stored frames. ++ /// Returns true only when photographic rows were rebuilt; false preserves unchanged samples. ++ /// ++ /// Only groundY and meter.y may differ. Nonzero pendingShift refuses a ground change without ++ /// mutation: existing rounded skip cells have no exact annotation coordinates to reproject. ++ /// A successful rebuild starts a fresh zero-residual grid, retains latent skip flags and ends, ++ /// and publishes revision old+1, independent of how many observe calls changed the fresh map. ++ /// The caller must still publish the map/geometry together, and handle refusal as unresolved. ++ @discardableResult ++ public mutating func replayGround(_ frame: WallFrame, acceptedCameras: [CameraFrame]) throws -> Bool { ++ guard frame.groundY.isFinite, frame.meter.y.isFinite else { ++ throw GroundReplayError.invalidHeight ++ } ++ guard frame.meter.x == wall.meter.x, frame.meter.z == wall.meter.z, ++ frame.outward == wall.outward else { ++ throw GroundReplayError.horizontalFrameChanged ++ } ++ guard frame.groundY != wall.groundY else { ++ wall = frame ++ return false ++ } ++ guard pendingShift == 0 else { throw GroundReplayError.unresolvedCellShift } ++ ++ var rebuilt = CoverageMap(wall: frame, config: config) ++ rebuilt.leftEnd = leftEnd ++ rebuilt.rightEnd = rightEnd ++ for (band, bandCells) in cells { ++ for (index, cell) in bandCells where cell.skipped { ++ var retained = Cell(rowCount: config.rowsPerBand) ++ retained.skipped = true ++ rebuilt.cells[band, default: [:]][index] = retained ++ } ++ } ++ for camera in acceptedCameras { ++ rebuilt.observe(camera, trackingNormal: true) ++ } ++ rebuilt.revision = revision + 1 ++ self = rebuilt ++ return true ++ } ++ + // MARK: Reading + + /// s extent of seen or covered cells, or nil when nothing has been seen. diff --git a/experiments/research-handoff/native-proposals/patch-identities.json b/experiments/research-handoff/native-proposals/patch-identities.json new file mode 100644 index 000000000..8dec3d965 --- /dev/null +++ b/experiments/research-handoff/native-proposals/patch-identities.json @@ -0,0 +1,23 @@ +{ + "scope": "Public-safe patch export only; no production edit or execution.", + "patches": [ + { + "patch": "scan-engine-task-guards.patch", + "baselineCommit": "a39d0a503412d688dd151f628cdeebc00f8221b6", + "target": "ios/HouseScan/Runtime/ScanEngine.swift", + "bytes": 1647, + "sha256": "94eb349baf51a0f72f938b4e3a25e6b547352fd46b1b2bf224270845bf17044d", + "hunks": 3, + "exportCheck": "Byte-identical to reviewed proposal; existing a/ and b/ labels are already relative. No application or compile check performed in handoff." + }, + { + "patch": "ground-replay-helper-unrun.patch", + "baselineCommit": "a39d0a503412d688dd151f628cdeebc00f8221b6", + "target": "ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift", + "bytes": 2778, + "sha256": "cfb471608f05f0bc724a52ee518469c5e0e2f050d797b10787c5dbbad049690c", + "hunks": 1, + "exportCheck": "Byte-identical to reviewed proposal; existing a/ and b/ labels are already relative. No application or compile check performed in handoff." + } + ] +} diff --git a/experiments/research-handoff/native-proposals/scan-engine-task-guards.patch b/experiments/research-handoff/native-proposals/scan-engine-task-guards.patch new file mode 100644 index 000000000..5166fb9f4 --- /dev/null +++ b/experiments/research-handoff/native-proposals/scan-engine-task-guards.patch @@ -0,0 +1,33 @@ +--- a/ios/HouseScan/Runtime/ScanEngine.swift ++++ b/ios/HouseScan/Runtime/ScanEngine.swift +@@ -280,6 +280,7 @@ + let scan = generation + let store = store + Task { ++ guard scan == generation, state.phase == .meterCloseUp else { return } + let saved = await store.saveStill(frame.jpeg, name: "meter_close.jpg") + guard scan == generation, state.phase == .meterCloseUp else { return } + if !saved { +@@ -289,6 +290,7 @@ + return + } + let thumbnail = await store.thumbnail(ofStill: "meter_close.jpg") ++ guard scan == generation, state.phase == .meterCloseUp else { return } + state.closeUp = .captured(thumbnail) + state.captureCount += 1 + state.lastCapture = CaptureEvent(id: state.captureCount, kind: .closeUp, thumbnail: thumbnail) +@@ -368,9 +370,12 @@ + let store = store + pendingSaves[scan, default: 0] += 1 + Task { ++ defer { ++ let left = (pendingSaves[scan] ?? 1) - 1 ++ pendingSaves[scan] = left > 0 ? left : nil ++ } ++ guard scan == generation else { return } + let saved = await store.saveKeyframe(frame.jpeg, index: index, camera: frame.camera) +- let left = (pendingSaves[scan] ?? 1) - 1 +- pendingSaves[scan] = left > 0 ? left : nil + guard scan == generation, saved.stored else { return } + // Coverage only moves on kept frames with normal tracking (checklist R3). + coverage?.observe(frame.camera, trackingNormal: frame.tracking == .normal)