diff --git a/artifacts/move-calibration/muninn-same-frame-correspondence.mpack b/artifacts/move-calibration/muninn-same-frame-correspondence.mpack new file mode 100644 index 0000000..e70c40c Binary files /dev/null and b/artifacts/move-calibration/muninn-same-frame-correspondence.mpack differ diff --git a/artifacts/move-calibration/muninn-same-frame-stereo-assessment.mpack b/artifacts/move-calibration/muninn-same-frame-stereo-assessment.mpack new file mode 100644 index 0000000..4de6fbf Binary files /dev/null and b/artifacts/move-calibration/muninn-same-frame-stereo-assessment.mpack differ diff --git a/notes/current-system-map.md b/notes/current-system-map.md index 4065333..c22b776 100644 --- a/notes/current-system-map.md +++ b/notes/current-system-map.md @@ -215,6 +215,13 @@ both eyes. The same parent PID survived the run; transport health reported 5,302 produced frames, 5,301 handoffs, 771 sends, and zero local-ring admissions. Full stereo calibration is still not implied: intrinsics and spatially diverse correspondences must earn the camera matrices. +`MimirMoveStereoCalibrationAssessment` is the typed observability gate before +any fit. It accepts only same aggregate-frame, same-Move correspondences and +reports synchronized pair count, Move diversity, per-Eye 4x3 grid coverage, +radius range, and missing physical inputs. The corrected live capture produced +130 same-frame pairs with zero frame skew, but only 4/12 Eye 0 cells and 2/12 +Eye 1 cells; intrinsics and measured orb radius are also absent, so promotion is +correctly false. `MimirMoveProofSurface` is the first Fensalir-visible proof surface for this chain. It consumes the Mimir admission receipt and the Mimir-owned pose stream frame, emits `mimir.move_proof_surface.v1`, and lowers an observer-only diff --git a/src/Mimir.BufferSmoke/Program.cs b/src/Mimir.BufferSmoke/Program.cs index a09172a..a48c4c4 100644 --- a/src/Mimir.BufferSmoke/Program.cs +++ b/src/Mimir.BufferSmoke/Program.cs @@ -154,6 +154,17 @@ if (args.Any(arg => string.Equals(arg, "--muninn-move-visibility-window-smoke", ParseStringOption(args, "--output", "artifacts/move-calibration/muninn-visibility-window.mpack")); } +if (args.Any(arg => string.Equals(arg, "--move-stereo-calibration-assessment", StringComparison.OrdinalIgnoreCase))) +{ + return RunMoveStereoCalibrationAssessment( + ParseStringOption(args, "--input", "artifacts/move-calibration/muninn-clocked-aggregator.mpack"), + ParseStringOption(args, "--output", "artifacts/move-calibration/stereo-assessment.mpack"), + ParseIntOption(args, "--image-width", 640), + ParseIntOption(args, "--image-height", 480), + args.Any(arg => string.Equals(arg, "--intrinsics-available", StringComparison.OrdinalIgnoreCase)), + ParseDoubleOption(args, "--orb-radius-meters", 0.0)); +} + if (args.Any(arg => string.Equals(arg, "--move-fusion-smoke", StringComparison.OrdinalIgnoreCase))) { return RunMoveFusionSmoke(); @@ -2071,16 +2082,20 @@ static int RunMuninnMoveVisibilityWindowSmoke(string odinCultMeshUri, int durati } var correspondences = observations - .GroupBy(observation => observation.MoveId, StringComparer.Ordinal) - .SelectMany(group => + .GroupBy(observation => (observation.FrameId, observation.MoveId)) + .Select(group => { - var first = group.Where(value => value.CameraId == cameraIds[0]).ToArray(); - var second = group.Where(value => value.CameraId == cameraIds[1]).ToArray(); - return first.Select(left => second - .Select(right => new MoveCrossCameraCorrespondence(left.MoveId, left, right, Math.Abs(left.PublishedAtNs - right.PublishedAtNs))) - .OrderBy(pair => pair.AbsoluteSkewNs).FirstOrDefault()) - .Where(pair => pair is not null)!; + var first = group.FirstOrDefault(value => value.CameraId == cameraIds[0]); + var second = group.FirstOrDefault(value => value.CameraId == cameraIds[1]); + return first is null || second is null + ? null + : new MoveCrossCameraCorrespondence( + group.Key.MoveId, + first, + second, + Math.Abs(first.PublishedAtNs - second.PublishedAtNs)); }) + .Where(pair => pair is not null) .Cast() .ToArray(); var receipt = new MoveVisibilityWindowReceipt( @@ -2115,6 +2130,65 @@ static int RunMuninnMoveVisibilityWindowSmoke(string odinCultMeshUri, int durati } } +static int RunMoveStereoCalibrationAssessment( + string inputPath, + string outputPath, + int imageWidth, + int imageHeight, + bool intrinsicsAvailable, + double orbRadiusMeters) +{ + if (!File.Exists(inputPath)) + { + Console.Error.WriteLine($"move-stereo-calibration-assessment missing-input path={inputPath}"); + return 1; + } + + var receipt = MessagePackSerializer.Deserialize(File.ReadAllBytes(inputPath)); + var cameraIds = receipt.Observations + .Select(value => value.CameraId) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(); + var assessment = MimirMoveStereoCalibrationAssessment.Assess( + receipt, + imageWidth, + imageHeight, + cameraIds, + intrinsicsAvailable, + orbRadiusMeters); + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(outputPath))!); + File.WriteAllBytes(outputPath, MessagePackSerializer.Serialize(assessment)); + Console.WriteLine(JsonSerializer.Serialize(new + { + assessment.Schema, + inputPath, + outputPath, + assessment.CorrespondenceCount, + assessment.SynchronizedCorrespondenceCount, + assessment.SameFrameCorrespondenceCount, + assessment.DistinctMoveCount, + assessment.MedianAbsoluteSkewMilliseconds, + assessment.MaximumAbsoluteSkewMilliseconds, + assessment.IntrinsicsAvailable, + assessment.OrbRadiusMetersAvailable, + assessment.ReadyForRelativePoseFit, + assessment.Promoted, + assessment.MissingRequirements, + cameras = assessment.Cameras.Select(camera => new + { + camera.CameraId, + camera.ObservationCount, + camera.OccupiedGridCells, + camera.GridCellCount, + spanX = camera.MaximumXPx - camera.MinimumXPx, + spanY = camera.MaximumYPx - camera.MinimumYPx, + radiusRatio = camera.MinimumRadiusPx > 0.0 ? camera.MaximumRadiusPx / camera.MinimumRadiusPx : double.NaN + }) + })); + return assessment.Promoted ? 0 : 2; +} + static int RunMuninnMoveCultMeshStreamSmoke(string nativeReservoirPath) { if (!File.Exists(nativeReservoirPath)) diff --git a/src/Mimir.Runtime/Synchronization/MimirMoveStereoCalibrationAssessment.cs b/src/Mimir.Runtime/Synchronization/MimirMoveStereoCalibrationAssessment.cs new file mode 100644 index 0000000..e43bd03 --- /dev/null +++ b/src/Mimir.Runtime/Synchronization/MimirMoveStereoCalibrationAssessment.cs @@ -0,0 +1,139 @@ +using GameCult.Caching; +using MessagePack; + +namespace Mimir.Runtime.Synchronization; + +[MessagePackObject] +public sealed record MimirMoveCameraCoverageAssessment( + [property: Key(0)] string CameraId, + [property: Key(1)] int ObservationCount, + [property: Key(2)] double MinimumXPx, + [property: Key(3)] double MaximumXPx, + [property: Key(4)] double MinimumYPx, + [property: Key(5)] double MaximumYPx, + [property: Key(6)] int OccupiedGridCells, + [property: Key(7)] int GridCellCount, + [property: Key(8)] double MinimumRadiusPx, + [property: Key(9)] double MaximumRadiusPx); + +[CultDocument("mimir.move_stereo_calibration_assessment", "mimir.move_stereo_calibration_assessment.v1")] +[MessagePackObject] +public sealed record MimirMoveStereoCalibrationAssessmentDocument( + [property: Key(0)] string Schema, + [property: Key(1)] [property: CultName] string AssessmentId, + [property: Key(2)] string SourceSchema, + [property: Key(3)] long AssessedAtNs, + [property: Key(4)] int CorrespondenceCount, + [property: Key(5)] int SynchronizedCorrespondenceCount, + [property: Key(6)] int SameFrameCorrespondenceCount, + [property: Key(7)] int DistinctMoveCount, + [property: Key(8)] double MedianAbsoluteSkewMilliseconds, + [property: Key(9)] double MaximumAbsoluteSkewMilliseconds, + [property: Key(10)] MimirMoveCameraCoverageAssessment[] Cameras, + [property: Key(11)] bool IntrinsicsAvailable, + [property: Key(12)] bool OrbRadiusMetersAvailable, + [property: Key(13)] bool ReadyForRelativePoseFit, + [property: Key(14)] bool Promoted, + [property: Key(15)] string[] MissingRequirements); + +public static class MimirMoveStereoCalibrationAssessment +{ + public const string Schema = "mimir.move_stereo_calibration_assessment.v1"; + + public static MimirMoveStereoCalibrationAssessmentDocument Assess( + MoveVisibilityWindowReceipt receipt, + int imageWidth, + int imageHeight, + IReadOnlyCollection cameraIds, + bool intrinsicsAvailable, + double orbRadiusMeters, + double maximumAssociationSkewMilliseconds = 20.0, + DateTimeOffset? assessedAt = null) + { + ArgumentNullException.ThrowIfNull(receipt); + ArgumentNullException.ThrowIfNull(cameraIds); + if (imageWidth <= 0 || imageHeight <= 0) throw new ArgumentOutOfRangeException(nameof(imageWidth)); + if (cameraIds.Count != 2) throw new ArgumentException("Stereo calibration requires exactly two camera IDs.", nameof(cameraIds)); + + var cameras = cameraIds.Distinct(StringComparer.Ordinal).ToArray(); + if (cameras.Length != 2) throw new ArgumentException("Stereo camera IDs must be distinct.", nameof(cameraIds)); + var maximumSkewNs = maximumAssociationSkewMilliseconds * 1_000_000.0; + var correspondences = receipt.Correspondences + .Where(value => cameras.Contains(value.First.CameraId, StringComparer.Ordinal) && + cameras.Contains(value.Second.CameraId, StringComparer.Ordinal)) + .ToArray(); + var synchronized = correspondences.Where(value => value.AbsoluteSkewNs <= maximumSkewNs).ToArray(); + var sameFrame = synchronized.Where(value => string.Equals(value.First.FrameId, value.Second.FrameId, StringComparison.Ordinal)).ToArray(); + var coverage = cameras.Select(cameraId => BuildCoverage( + cameraId, + receipt.Observations.Where(value => string.Equals(value.CameraId, cameraId, StringComparison.Ordinal)).ToArray(), + imageWidth, + imageHeight)).ToArray(); + var missing = new List(); + if (!intrinsicsAvailable) missing.Add("calibrated-intrinsics-for-both-eyes"); + if (!(double.IsFinite(orbRadiusMeters) && orbRadiusMeters > 0.0)) missing.Add("measured-orb-radius-meters"); + if (synchronized.Length < 300) missing.Add("at-least-300-synchronized-correspondences"); + if (sameFrame.Length < 100) missing.Add("at-least-100-same-frame-correspondences"); + if (synchronized.Select(value => value.MoveId).Distinct(StringComparer.Ordinal).Count() < 2) missing.Add("at-least-two-stable-move-identities"); + foreach (var camera in coverage) + { + if (camera.OccupiedGridCells < 6) missing.Add($"{camera.CameraId}:coverage-at-least-6-of-12-grid-cells"); + if (camera.MaximumRadiusPx <= camera.MinimumRadiusPx * 1.25) missing.Add($"{camera.CameraId}:radius-range-at-least-1.25x"); + } + + var skews = correspondences.Select(value => value.AbsoluteSkewNs / 1_000_000.0).Order().ToArray(); + var ready = missing.Count == 0; + return new MimirMoveStereoCalibrationAssessmentDocument( + Schema, + $"move-stereo-assessment:{receipt.StartedAtNs}:{receipt.EndedAtNs}", + receipt.Schema, + (assessedAt ?? DateTimeOffset.UtcNow).ToUnixTimeMilliseconds() * 1_000_000, + correspondences.Length, + synchronized.Length, + sameFrame.Length, + synchronized.Select(value => value.MoveId).Distinct(StringComparer.Ordinal).Count(), + Median(skews), + skews.Length == 0 ? double.NaN : skews[^1], + coverage, + intrinsicsAvailable, + double.IsFinite(orbRadiusMeters) && orbRadiusMeters > 0.0, + ready, + Promoted: false, + missing.Distinct(StringComparer.Ordinal).ToArray()); + } + + private static MimirMoveCameraCoverageAssessment BuildCoverage( + string cameraId, + MoveVisibilityObservation[] observations, + int imageWidth, + int imageHeight) + { + var valid = observations.Where(value => + float.IsFinite(value.CenterXPx) && float.IsFinite(value.CenterYPx) && + float.IsFinite(value.RadiusPx) && value.RadiusPx > 0.0f).ToArray(); + var cells = valid.Select(value => + { + var column = Math.Clamp((int)(value.CenterXPx * 4.0 / imageWidth), 0, 3); + var row = Math.Clamp((int)(value.CenterYPx * 3.0 / imageHeight), 0, 2); + return row * 4 + column; + }).Distinct().Count(); + return new MimirMoveCameraCoverageAssessment( + cameraId, + valid.Length, + valid.Length == 0 ? double.NaN : valid.Min(value => value.CenterXPx), + valid.Length == 0 ? double.NaN : valid.Max(value => value.CenterXPx), + valid.Length == 0 ? double.NaN : valid.Min(value => value.CenterYPx), + valid.Length == 0 ? double.NaN : valid.Max(value => value.CenterYPx), + cells, + 12, + valid.Length == 0 ? double.NaN : valid.Min(value => value.RadiusPx), + valid.Length == 0 ? double.NaN : valid.Max(value => value.RadiusPx)); + } + + private static double Median(double[] sorted) => sorted.Length switch + { + 0 => double.NaN, + _ when sorted.Length % 2 == 1 => sorted[sorted.Length / 2], + _ => (sorted[sorted.Length / 2 - 1] + sorted[sorted.Length / 2]) * 0.5 + }; +} diff --git a/state/evidence.jsonl b/state/evidence.jsonl index cecedf0..53aca53 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -184,3 +184,4 @@ {"ts": "2026-07-13T01:29:15+01:00", "type": "verification", "status": "partial", "note": "Per-camera optical evidence and exposure ownership restored Eye 0. At exposure 0.3 Eye 0 peak RGB rose from [72,25,43] to [178,119,112], calibrated 4 Moves, and produced 3,312 observations/latest 4; Eye 1 remains at exposure 0.1. Mimir's persistent window then saw all 4 IDs on Eye 0, 2 on Eye 1, and 2 same-ID cross-camera correspondences. Do not claim full stereo: Eye 1 overlap is incomplete and only 2 aggregate RUDP frames arrived in 20s and 60s windows."} {"ts": "2026-07-13T01:41:29+01:00", "type": "verification", "status": "partial", "note": "After per-eye exposure tuning and latest-state RUDP handoff, Mimir's 15s dual-eye receipt saw all four stable Move IDs on both cameras: 16 camera observations and 8 pair records across 2 aggregate frames. Full overlap is proven; calibration is not. Only 2 unique frame IDs arrived, so directly instrument parent publication and RUDP send/receive counters before camera-matrix fitting."} {"ts": "2026-07-13T02:32:31+01:00", "type": "verification", "status": "ok", "note": "2026-07-13 Nightwing single-provider dual-Eye field proof: one stable Muninn parent received 622 unique evidence frames in 15 s through Mimir, with 2,647 observations, 1,384 same-ID cross-camera correspondences, and all four Move IDs visible on both Eyes. Producer transport health reported 5,302 produced, 5,301 handoffs, 771 sends, and zero local-ring admissions. Artifact: artifacts/move-calibration/muninn-clocked-aggregator.mpack. Odin owner commits: 39c839a, a340c33, 8aefd1c, f82ca7a."} +{"ts": "2026-07-13T02:39:32+01:00", "type": "verification", "status": "rejected", "note": "2026-07-13 typed Move stereo assessment rejected calibration promotion. Corrected same-frame collector captured 798 frames and 130 same-frame/same-Move pairs at zero published-frame skew. Eye 0 covered 4/12 image grid cells; Eye 1 covered 2/12 and saw only two Move IDs in this window. Both calibrated intrinsics and measured orb radius were absent. No camera matrices were emitted. Artifacts: artifacts/move-calibration/muninn-same-frame-correspondence.mpack and muninn-same-frame-stereo-assessment.mpack."} diff --git a/state/map.yaml b/state/map.yaml index 9869434..1ef68ed 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -267,6 +267,14 @@ accepted_design: remote handoffs, 771 sends during the subscription, and zero local-ring admissions. Cadence and full overlap are earned; calibrated camera matrices still require intrinsics and spatially diverse correspondence capture. + - `MimirMoveStereoCalibrationAssessment` owns the pre-fit observability gate + as `mimir.move_stereo_calibration_assessment.v1`. It consumes a typed + visibility receipt, counts only same-frame/same-Move stereo pairs, measures + per-Eye image-grid and radius coverage, and refuses promotion when + intrinsics, measured orb radius, synchronized sample count, or spatial + diversity are missing. The corrected 2026-07-13 live receipt contained 130 + honest same-frame pairs at zero frame skew, but Eye 0 occupied only 4/12 + grid cells and Eye 1 only 2/12; no camera matrices were promoted. - `MimirMuninnMoveEvidenceAdapter.TryAdmitLatestCultMeshFrame` now exposes a typed admission receipt with decoded frame id, producer, publish/read timestamps, sample counts by kind, source/arrival ranges, native reservoir