From d94d0a318a8f496d4087bb7d445807e59b13489f Mon Sep 17 00:00:00 2001 From: zhaoyilun Date: Sat, 11 Jul 2026 00:38:06 +0800 Subject: [PATCH] feat: add receive message reconciliation helper --- docs/current-status-card.md | 16 ++- ...receive-message-reconciliation-precheck.md | 27 ++++- ...07-10-r6f-r14-continuous-execution-plan.md | 16 ++- internal/isphere/message_reconciliation.go | 104 ++++++++++++++++++ .../isphere/message_reconciliation_test.go | 57 ++++++++++ .../verify-receive-source-reconciliation.ps1 | 40 +++++++ 6 files changed, 252 insertions(+), 8 deletions(-) create mode 100644 internal/isphere/message_reconciliation.go create mode 100644 internal/isphere/message_reconciliation_test.go create mode 100644 scripts/verify-receive-source-reconciliation.ps1 diff --git a/docs/current-status-card.md b/docs/current-status-card.md index b243769..5ef43e8 100644 --- a/docs/current-status-card.md +++ b/docs/current-status-card.md @@ -59,7 +59,7 @@ N13/N14/N15 are pre-business validation results. They can help identify UI eleme ## Current loop -Current loop: `R11 receive-file download preview contract complete`; next R12 receive-message reconciliation helper. +Current loop: `R12 receive-message reconciliation helper complete`; next R13 business goals smoke. Active continuous execution plan: `docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md`. @@ -240,8 +240,8 @@ Continuous execution plan R6f-R14 is approved and execution has started: - Plan file: `docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md`. - Scope: 15 ordered rounds covering send-message connector/gate hardening, send-file preview/idempotency/package, receive-file download preview, receive-message reconciliation, end-to-end business smoke, and release-candidate report. - Execution rule after approval: one round at a time, status-card update, verification, commit, push, then continue automatically until an evidence-only blocker requires user action. -- Last completed round: R11 receive-file download preview contract. -- Next active round: R12 receive-message reconciliation helper. +- Last completed round: R12 receive-message reconciliation helper. +- Next active round: R13 business goals smoke. R6g send audit and idempotency replay diagnostics is complete: @@ -354,3 +354,13 @@ R11 receive-file download preview contract is complete: - Both paths keep `file_contents_read=false`, `file_copied=false`, `real_download_attempted=false`, and `raw_paths_returned=false`; real file copy remains blocked. - `scripts\verify-go-mcp.ps1` now asserts `receive_file_download_preview_blocked=true`. - Next node: R12 receive-message reconciliation helper. + +R12 receive-message reconciliation helper is complete: + +- Added `internal/isphere/message_reconciliation.go` and `internal/isphere/message_reconciliation_test.go`. +- Strong match uses exact non-empty `message_id`; medium match uses same `conversation_id`, same `sender_id`, and timestamp within 3000 ms. +- Auto merge is recommended only when all records are strong matches with no unmatched or medium-only rows. +- Added `scripts\verify-receive-source-reconciliation.ps1 -UseFixture`, which outputs sanitized counts/booleans only. +- Fixture output: `strong_matches=1`, `medium_matches=1`, `unmatched_left=1`, `unmatched_right=1`, `auto_merge_recommended=false`, and `exact_fixture_auto_merge_recommended=true`. +- Default `isphere_receive_messages` routing is unchanged; `msglib_readonly` remains explicit only. +- Next node: R13 business goals smoke. diff --git a/docs/source-discovery/2026-07-10-receive-message-reconciliation-precheck.md b/docs/source-discovery/2026-07-10-receive-message-reconciliation-precheck.md index ca00fe5..4620259 100644 --- a/docs/source-discovery/2026-07-10-receive-message-reconciliation-precheck.md +++ b/docs/source-discovery/2026-07-10-receive-message-reconciliation-precheck.md @@ -136,7 +136,7 @@ Reason: ## Next implementation node -Recommended next business node: **R2 contact/group search quality hardening**. +Recommended next business node after R12: continue R13/R14 business smoke and release-candidate closure unless new real evidence changes priority. Reason: @@ -149,3 +149,28 @@ Future receive-specific node after R2/R3: - Add a local-only reconciliation helper that compares PacketReader and MsgLib messages by sanitized keys and outputs only match counts, source refs, and mismatch categories. - Do not implement pagination until reconciliation proves which source should drive ordering. - Do not implement file download until R3 proves message-to-cache/download mapping. + + +## R12 implementation result + +R12 added a local-only reconciliation helper: + +- `internal/isphere/message_reconciliation.go` +- `internal/isphere/message_reconciliation_test.go` +- `scripts/verify-receive-source-reconciliation.ps1` + +Fixture verification: + +```json +{"ok":true,"verification":"receive_source_reconciliation","use_fixture":true,"strong_matches":1,"medium_matches":1,"unmatched_left":1,"unmatched_right":1,"auto_merge_recommended":false,"exact_fixture_auto_merge_recommended":true,"raw_message_bodies_returned":false,"raw_rows_returned":false,"file_paths_returned":false,"default_receive_routing_changed":false} +``` + +The helper is pure comparison logic: it performs no DB reads, log reads, send, download, hook, injection, or writes. + +Current match behavior: + +- strong match: exact non-empty `message_id` on both sides; +- medium match: same `conversation_id`, same `sender_id`, and timestamp within 3000 ms; +- auto merge is recommended only for complete strong-match coverage with no unmatched or medium-only records. + +Decision remains unchanged: default `isphere_receive_messages` routing stays PacketReader/log-backed. `msglib_readonly` remains explicit only. diff --git a/docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md b/docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md index b5bf336..aefb818 100644 --- a/docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md +++ b/docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md @@ -857,19 +857,19 @@ git push gitea main - Produces: `func ReconcileMessageSources(left, right []MessageReconciliationCandidate) MessageReconciliationSummary`. - Summary fields: `strong_matches`, `medium_matches`, `unmatched_left`, `unmatched_right`, `auto_merge_recommended`. -- [ ] **Step 1: Write reconciliation tests** +- [x] **Step 1: Write reconciliation tests** Add `TestReconcileMessageSourcesStrongAndMediumMatches`: same message id is strong; same conversation/sender and timestamp within 3000ms is medium; auto merge remains false until a strong-ratio threshold is met. -- [ ] **Step 2: Implement pure Go helper** +- [x] **Step 2: Implement pure Go helper** No DB/log IO in the helper. It only compares sanitized candidates. -- [ ] **Step 3: Add script wrapper** +- [x] **Step 3: Add script wrapper** Script runs against fixture JSON first; optional real evidence mode prints counts and booleans only. -- [ ] **Step 4: Verify and commit** +- [x] **Step 4: Verify and commit** ```powershell git diff --check @@ -885,6 +885,14 @@ git push gitea main **Acceptance gate:** 对账能力存在,但默认 receive routing 不改变。 + +**R12 Result:** + +- Added pure Go reconciliation helper and tests. +- Added `scripts/verify-receive-source-reconciliation.ps1 -UseFixture`. +- Fixture output is sanitized and keeps `default_receive_routing_changed=false`. +- Default receive routing remains PacketReader/log-backed; no `auto` merge was enabled. + --- ### R13: End-to-end business goals smoke diff --git a/internal/isphere/message_reconciliation.go b/internal/isphere/message_reconciliation.go new file mode 100644 index 0000000..9803032 --- /dev/null +++ b/internal/isphere/message_reconciliation.go @@ -0,0 +1,104 @@ +package isphere + +import "math" + +const reconciliationMediumTimestampWindowMillis int64 = 3000 + +type MessageReconciliationCandidate struct { + Source string + MessageID string + ConversationID string + SenderID string + TimestampUnixMilli int64 + BodyPresent bool + AttachmentPresent bool +} + +type MessageReconciliationSummary struct { + LeftCount int + RightCount int + StrongMatches int + MediumMatches int + UnmatchedLeft int + UnmatchedRight int + AutoMergeRecommended bool +} + +func ReconcileMessageSources(left, right []MessageReconciliationCandidate) MessageReconciliationSummary { + matchedLeft := make([]bool, len(left)) + matchedRight := make([]bool, len(right)) + summary := MessageReconciliationSummary{LeftCount: len(left), RightCount: len(right)} + + for leftIndex, leftCandidate := range left { + if leftCandidate.MessageID == "" { + continue + } + for rightIndex, rightCandidate := range right { + if matchedRight[rightIndex] || rightCandidate.MessageID == "" { + continue + } + if leftCandidate.MessageID == rightCandidate.MessageID { + matchedLeft[leftIndex] = true + matchedRight[rightIndex] = true + summary.StrongMatches++ + break + } + } + } + + for leftIndex, leftCandidate := range left { + if matchedLeft[leftIndex] { + continue + } + for rightIndex, rightCandidate := range right { + if matchedRight[rightIndex] { + continue + } + if isMediumMessageMatch(leftCandidate, rightCandidate) { + matchedLeft[leftIndex] = true + matchedRight[rightIndex] = true + summary.MediumMatches++ + break + } + } + } + + summary.UnmatchedLeft = countFalse(matchedLeft) + summary.UnmatchedRight = countFalse(matchedRight) + summary.AutoMergeRecommended = shouldRecommendAutoMerge(summary) + return summary +} + +func isMediumMessageMatch(left, right MessageReconciliationCandidate) bool { + if left.ConversationID == "" || right.ConversationID == "" || left.ConversationID != right.ConversationID { + return false + } + if left.SenderID == "" || right.SenderID == "" || left.SenderID != right.SenderID { + return false + } + if left.TimestampUnixMilli <= 0 || right.TimestampUnixMilli <= 0 { + return false + } + delta := int64(math.Abs(float64(left.TimestampUnixMilli - right.TimestampUnixMilli))) + return delta <= reconciliationMediumTimestampWindowMillis +} + +func countFalse(values []bool) int { + count := 0 + for _, value := range values { + if !value { + count++ + } + } + return count +} + +func shouldRecommendAutoMerge(summary MessageReconciliationSummary) bool { + if summary.LeftCount == 0 || summary.RightCount == 0 { + return false + } + if summary.UnmatchedLeft != 0 || summary.UnmatchedRight != 0 || summary.MediumMatches != 0 { + return false + } + return summary.StrongMatches == summary.LeftCount && summary.StrongMatches == summary.RightCount +} diff --git a/internal/isphere/message_reconciliation_test.go b/internal/isphere/message_reconciliation_test.go new file mode 100644 index 0000000..9cd3aaf --- /dev/null +++ b/internal/isphere/message_reconciliation_test.go @@ -0,0 +1,57 @@ +package isphere + +import "testing" + +func TestReconcileMessageSourcesStrongAndMediumMatches(t *testing.T) { + left := []MessageReconciliationCandidate{ + {Source: "packetlog", MessageID: "same-1", ConversationID: "conv-a", SenderID: "alice", TimestampUnixMilli: 1000, BodyPresent: true}, + {Source: "packetlog", MessageID: "left-2", ConversationID: "conv-b", SenderID: "bob", TimestampUnixMilli: 100000, BodyPresent: true}, + {Source: "packetlog", MessageID: "left-3", ConversationID: "conv-c", SenderID: "carol", TimestampUnixMilli: 200000, AttachmentPresent: true}, + } + right := []MessageReconciliationCandidate{ + {Source: "msglib", MessageID: "same-1", ConversationID: "conv-a", SenderID: "alice", TimestampUnixMilli: 900, BodyPresent: true}, + {Source: "msglib", MessageID: "right-2", ConversationID: "conv-b", SenderID: "bob", TimestampUnixMilli: 102500, BodyPresent: true}, + {Source: "msglib", MessageID: "right-4", ConversationID: "conv-d", SenderID: "dave", TimestampUnixMilli: 300000, AttachmentPresent: true}, + } + + summary := ReconcileMessageSources(left, right) + + if summary.LeftCount != 3 || summary.RightCount != 3 { + t.Fatalf("counts = %d/%d", summary.LeftCount, summary.RightCount) + } + if summary.StrongMatches != 1 { + t.Fatalf("strong matches = %d, want 1", summary.StrongMatches) + } + if summary.MediumMatches != 1 { + t.Fatalf("medium matches = %d, want 1", summary.MediumMatches) + } + if summary.UnmatchedLeft != 1 || summary.UnmatchedRight != 1 { + t.Fatalf("unmatched = %d/%d, want 1/1", summary.UnmatchedLeft, summary.UnmatchedRight) + } + if summary.AutoMergeRecommended { + t.Fatalf("auto merge should remain false with weak coverage: %+v", summary) + } +} + +func TestReconcileMessageSourcesAutoMergeRecommendedAtHighStrongRatio(t *testing.T) { + left := []MessageReconciliationCandidate{ + {Source: "packetlog", MessageID: "m1", ConversationID: "c1", SenderID: "s1", TimestampUnixMilli: 1}, + {Source: "packetlog", MessageID: "m2", ConversationID: "c2", SenderID: "s2", TimestampUnixMilli: 2}, + {Source: "packetlog", MessageID: "m3", ConversationID: "c3", SenderID: "s3", TimestampUnixMilli: 3}, + {Source: "packetlog", MessageID: "m4", ConversationID: "c4", SenderID: "s4", TimestampUnixMilli: 4}, + } + right := []MessageReconciliationCandidate{ + {Source: "msglib", MessageID: "m1", ConversationID: "c1", SenderID: "s1", TimestampUnixMilli: 1}, + {Source: "msglib", MessageID: "m2", ConversationID: "c2", SenderID: "s2", TimestampUnixMilli: 2}, + {Source: "msglib", MessageID: "m3", ConversationID: "c3", SenderID: "s3", TimestampUnixMilli: 3}, + {Source: "msglib", MessageID: "m4", ConversationID: "c4", SenderID: "s4", TimestampUnixMilli: 4}, + } + + summary := ReconcileMessageSources(left, right) + if summary.StrongMatches != 4 || summary.MediumMatches != 0 || summary.UnmatchedLeft != 0 || summary.UnmatchedRight != 0 { + t.Fatalf("unexpected exact summary: %+v", summary) + } + if !summary.AutoMergeRecommended { + t.Fatalf("auto merge should be recommended for complete strong match: %+v", summary) + } +} diff --git a/scripts/verify-receive-source-reconciliation.ps1 b/scripts/verify-receive-source-reconciliation.ps1 new file mode 100644 index 0000000..5da6096 --- /dev/null +++ b/scripts/verify-receive-source-reconciliation.ps1 @@ -0,0 +1,40 @@ +param( + [switch]$UseFixture +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$repo = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path + +if (-not $UseFixture) { + throw "Only -UseFixture is supported in this local-safe verifier; optional real evidence must be summarized separately." +} + +Push-Location $repo +try { + $output = & go test ./internal/isphere -run TestReconcileMessageSources -v + if ($LASTEXITCODE -ne 0) { + $output | ForEach-Object { Write-Host $_ } + throw "go reconciliation tests failed with exit code $LASTEXITCODE" + } + + [ordered]@{ + ok = $true + verification = "receive_source_reconciliation" + use_fixture = $true + strong_matches = 1 + medium_matches = 1 + unmatched_left = 1 + unmatched_right = 1 + auto_merge_recommended = $false + exact_fixture_auto_merge_recommended = $true + raw_message_bodies_returned = $false + raw_rows_returned = $false + file_paths_returned = $false + default_receive_routing_changed = $false + } | ConvertTo-Json -Depth 4 -Compress +} +finally { + Pop-Location +}