fix: harden real history replay evidence

This commit is contained in:
zgw
2026-07-31 09:56:29 +08:00
parent af0b52d690
commit 19807a5bf0
7 changed files with 185 additions and 65 deletions

View File

@@ -1,6 +1,6 @@
# WCS Real Queue History Replay Implementation Plan # WCS Real Queue History Replay Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
**Goal:** Build a reproducible, read-only offline replay of FT546's real SRGV queue history, test at least 200 original records, scan the complete 17,136-row history through database aggregates, and produce a Chinese-readable report that separates database facts from SHADOW inference. **Goal:** Build a reproducible, read-only offline replay of FT546's real SRGV queue history, test at least 200 original records, scan the complete 17,136-row history through database aggregates, and produce a Chinese-readable report that separates database facts from SHADOW inference.
@@ -8,6 +8,10 @@
**Tech Stack:** Java 8, Maven, Jackson 2.17.2, JUnit 5.10.2, SQL Server read-only MCP, `@oai/artifact-tool` for the final `.xlsx`. **Tech Stack:** Java 8, Maven, Jackson 2.17.2, JUnit 5.10.2, SQL Server read-only MCP, `@oai/artifact-tool` for the final `.xlsx`.
**Status (2026-07-31):** Completed and independently reviewed. Final evidence run:
`outputs/wcs-shadow-history-replay-20260731-0945-af0b52d`; Maven verification:
354 tests, 0 failures, 0 errors, 0 skipped.
## Global Constraints ## Global Constraints
- All database access is single-statement `SELECT`/CTE and read-only; no stored procedures or mutations. - All database access is single-statement `SELECT`/CTE and read-only; no stored procedures or mutations.
@@ -72,7 +76,7 @@
- Consumes: `FT546-wcs.dbo.wcs_srgv_queue_finish`, `FT546-wcs.dbo.wcs_srgv_map`. - Consumes: `FT546-wcs.dbo.wcs_srgv_queue_finish`, `FT546-wcs.dbo.wcs_srgv_map`.
- Produces: JSON arrays with original database fields and a manifest containing `database`, `table`, `extractedAt`, `totalRows`, `sampleRows`, `queryId`, and SHA-256 values. - Produces: JSON arrays with original database fields and a manifest containing `database`, `table`, `extractedAt`, `totalRows`, `sampleRows`, `queryId`, and SHA-256 values.
- [ ] **Step 1: Run the complete-history aggregate scan** - [x] **Step 1: Run the complete-history aggregate scan**
Use the SQL Server read-only tool with: Use the SQL Server read-only tool with:
@@ -94,7 +98,7 @@ FROM dbo.wcs_srgv_queue_finish
Expected: one row; `total_rows >= 17136`; query is read-only. Expected: one row; `total_rows >= 17136`; query is read-only.
- [ ] **Step 2: Select a deterministic representative sample** - [x] **Step 2: Select a deterministic representative sample**
Use one CTE query. The final `ORDER BY` is mandatory for reproducibility: Use one CTE query. The final `ORDER BY` is mandatory for reproducibility:
@@ -130,7 +134,7 @@ ORDER BY
Expected: 240 original rows when available, never fewer than 200. Expected: 240 original rows when available, never fewer than 200.
- [ ] **Step 3: Export the complete history through deterministic read-only pages** - [x] **Step 3: Export the complete history through deterministic read-only pages**
Use pages of 500 rows and change only the two numeric bounds for each page: Use pages of 500 rows and change only the two numeric bounds for each page:
@@ -158,7 +162,7 @@ Continue with `501 AND 1000` until the returned page is empty. Stream pages into
route the complete payload through chat text. The final row count must equal the route the complete payload through chat text. The final row count must equal the
aggregate scan exactly. aggregate scan exactly.
- [ ] **Step 4: Export referenced map sites** - [x] **Step 4: Export referenced map sites**
Read the full non-sensitive map projection, then retain only site IDs referenced Read the full non-sensitive map projection, then retain only site IDs referenced
by the sample and complete snapshot: by the sample and complete snapshot:
@@ -172,7 +176,7 @@ ORDER BY site_id, z, y, x
Do not query credentials or device IP fields. Do not query credentials or device IP fields.
- [ ] **Step 5: Write snapshot files and hashes** - [x] **Step 5: Write snapshot files and hashes**
Write JSON with UTF-8, stable property order, and two-space indentation. Compute: Write JSON with UTF-8, stable property order, and two-space indentation. Compute:
@@ -186,7 +190,7 @@ SHA-256(full-scan-summary.json)
source manifest additionally contains the complete snapshot hash. It must not source manifest additionally contains the complete snapshot hash. It must not
contain connection strings. contain connection strings.
- [ ] **Step 6: Verify extraction evidence** - [x] **Step 6: Verify extraction evidence**
Run: Run:
@@ -196,7 +200,7 @@ grep -RniE "password|token|jdbc:|server=" javaTest/src/test/resources/shadow/his
Expected: no matches. Expected: no matches.
- [ ] **Step 7: Commit only the bounded immutable test snapshot** - [x] **Step 7: Commit only the bounded immutable test snapshot**
```bash ```bash
git add javaTest/src/test/resources/shadow/history git add javaTest/src/test/resources/shadow/history
@@ -219,7 +223,7 @@ git commit -m "test: add real queue history replay snapshot"
- `HistoricalQueueDataset.getRecords()` returns an unmodifiable `List<HistoricalQueueRecord>`. - `HistoricalQueueDataset.getRecords()` returns an unmodifiable `List<HistoricalQueueRecord>`.
- `HistoricalQueueDataset.getMapSites()` returns an unmodifiable `Set<String>`. - `HistoricalQueueDataset.getMapSites()` returns an unmodifiable `Set<String>`.
- [ ] **Step 1: Write failing loader tests** - [x] **Step 1: Write failing loader tests**
```java ```java
@Test @Test
@@ -239,7 +243,7 @@ void rejectsHashMismatch() {
} }
``` ```
- [ ] **Step 2: Run loader tests and confirm RED** - [x] **Step 2: Run loader tests and confirm RED**
```bash ```bash
mvn -f javaTest/pom.xml -Dtest=HistoricalQueueLoaderTest test mvn -f javaTest/pom.xml -Dtest=HistoricalQueueLoaderTest test
@@ -247,7 +251,7 @@ mvn -f javaTest/pom.xml -Dtest=HistoricalQueueLoaderTest test
Expected: compilation failure because history classes do not exist. Expected: compilation failure because history classes do not exist.
- [ ] **Step 3: Implement immutable raw records and dataset** - [x] **Step 3: Implement immutable raw records and dataset**
`HistoricalQueueRecord` must expose exact string values for IDs/sites/dates and retain all extracted columns. Constructor arguments are JSON-mapped with Jackson `@JsonCreator` and `@JsonProperty`. `HistoricalQueueRecord` must expose exact string values for IDs/sites/dates and retain all extracted columns. Constructor arguments are JSON-mapped with Jackson `@JsonCreator` and `@JsonProperty`.
@@ -258,7 +262,7 @@ this.records = Collections.unmodifiableList(new ArrayList<HistoricalQueueRecord>
this.mapSites = Collections.unmodifiableSet(new LinkedHashSet<String>(mapSites)); this.mapSites = Collections.unmodifiableSet(new LinkedHashSet<String>(mapSites));
``` ```
- [ ] **Step 4: Implement loader and SHA validation** - [x] **Step 4: Implement loader and SHA validation**
The loader reads the four classpath resources, verifies every hash before deserializing, and checks: The loader reads the four classpath resources, verifies every hash before deserializing, and checks:
@@ -271,7 +275,7 @@ if (records.size() < 200) {
} }
``` ```
- [ ] **Step 5: Run loader tests and confirm GREEN** - [x] **Step 5: Run loader tests and confirm GREEN**
```bash ```bash
mvn -f javaTest/pom.xml -Dtest=HistoricalQueueLoaderTest test mvn -f javaTest/pom.xml -Dtest=HistoricalQueueLoaderTest test
@@ -279,7 +283,7 @@ mvn -f javaTest/pom.xml -Dtest=HistoricalQueueLoaderTest test
Expected: all loader tests pass. Expected: all loader tests pass.
- [ ] **Step 6: Commit** - [x] **Step 6: Commit**
```bash ```bash
git add javaTest/src/main/java/com/ft546/wcs/shadow/history javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueLoaderTest.java git add javaTest/src/main/java/com/ft546/wcs/shadow/history javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueLoaderTest.java
@@ -299,7 +303,7 @@ git commit -m "feat: load versioned queue history snapshots"
- Produces: `HistoricalPath HistoricalQueueNormalizer.normalize(HistoricalQueueRecord record, Set<String> mapSites)`. - Produces: `HistoricalPath HistoricalQueueNormalizer.normalize(HistoricalQueueRecord record, Set<String> mapSites)`.
- `HistoricalPath` exposes `getNodes()`, `getResources()`, `getCreatedAt()`, `getFinishedAt()`, `getIssues()`, and `isReplayable()`. - `HistoricalPath` exposes `getNodes()`, `getResources()`, `getCreatedAt()`, `getFinishedAt()`, `getIssues()`, and `isReplayable()`.
- [ ] **Step 1: Write failing normalization tests** - [x] **Step 1: Write failing normalization tests**
```java ```java
@Test @Test
@@ -327,7 +331,7 @@ void reversedIntervalIsNotReplayable() {
} }
``` ```
- [ ] **Step 2: Run normalization tests and confirm RED** - [x] **Step 2: Run normalization tests and confirm RED**
```bash ```bash
mvn -f javaTest/pom.xml -Dtest=HistoricalQueueNormalizerTest test mvn -f javaTest/pom.xml -Dtest=HistoricalQueueNormalizerTest test
@@ -335,7 +339,7 @@ mvn -f javaTest/pom.xml -Dtest=HistoricalQueueNormalizerTest test
Expected: compilation failure because normalizer classes do not exist. Expected: compilation failure because normalizer classes do not exist.
- [ ] **Step 3: Implement strict timestamp parsing** - [x] **Step 3: Implement strict timestamp parsing**
Use: Use:
@@ -346,7 +350,7 @@ DateTimeFormatter formatter = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss")
Parse into `LocalDateTime`. Null, blank, invalid, or reversed intervals add structured issues and prevent replay. Parse into `LocalDateTime`. Null, blank, invalid, or reversed intervals add structured issues and prevent replay.
- [ ] **Step 4: Implement path normalization and resource projection** - [x] **Step 4: Implement path normalization and resource projection**
Rules: Rules:
@@ -362,7 +366,7 @@ do not create CONFLICT or lift resources
Empty paths add `EMPTY_PATH` and return `isReplayable() == false`. Empty paths add `EMPTY_PATH` and return `isReplayable() == false`.
- [ ] **Step 5: Run tests and confirm GREEN** - [x] **Step 5: Run tests and confirm GREEN**
```bash ```bash
mvn -f javaTest/pom.xml -Dtest=HistoricalQueueNormalizerTest test mvn -f javaTest/pom.xml -Dtest=HistoricalQueueNormalizerTest test
@@ -370,7 +374,7 @@ mvn -f javaTest/pom.xml -Dtest=HistoricalQueueNormalizerTest test
Expected: all normalization tests pass. Expected: all normalization tests pass.
- [ ] **Step 6: Commit** - [x] **Step 6: Commit**
```bash ```bash
git add javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalPath.java javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueNormalizer.java javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueNormalizerTest.java git add javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalPath.java javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueNormalizer.java javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueNormalizerTest.java
@@ -392,7 +396,7 @@ git commit -m "feat: normalize real queue paths fail closed"
- Produces: `boolean HistoricalReplayAnalyzer.overlaps(HistoricalPath left, HistoricalPath right)`. - Produces: `boolean HistoricalReplayAnalyzer.overlaps(HistoricalPath left, HistoricalPath right)`.
- Stable ordering: priority ascending, `createdAt`, task number, AGV ID, GUID. - Stable ordering: priority ascending, `createdAt`, task number, AGV ID, GUID.
- [ ] **Step 1: Write failing overlap and conflict tests** - [x] **Step 1: Write failing overlap and conflict tests**
```java ```java
@Test @Test
@@ -418,7 +422,7 @@ void identicalInputProducesIdenticalResults() {
} }
``` ```
- [ ] **Step 2: Run analyzer tests and confirm RED** - [x] **Step 2: Run analyzer tests and confirm RED**
```bash ```bash
mvn -f javaTest/pom.xml -Dtest=HistoricalReplayAnalyzerTest test mvn -f javaTest/pom.xml -Dtest=HistoricalReplayAnalyzerTest test
@@ -426,7 +430,7 @@ mvn -f javaTest/pom.xml -Dtest=HistoricalReplayAnalyzerTest test
Expected: compilation failure because analyzer types do not exist. Expected: compilation failure because analyzer types do not exist.
- [ ] **Step 3: Implement half-open overlap semantics** - [x] **Step 3: Implement half-open overlap semantics**
```java ```java
return left.getCreatedAt().isBefore(right.getFinishedAt()) return left.getCreatedAt().isBefore(right.getFinishedAt())
@@ -435,7 +439,7 @@ return left.getCreatedAt().isBefore(right.getFinishedAt())
Non-replayable paths return `INSUFFICIENT_DATA` before conflict checks. Non-replayable paths return `INSUFFICIENT_DATA` before conflict checks.
- [ ] **Step 4: Implement conflict classification** - [x] **Step 4: Implement conflict classification**
For overlapping records: For overlapping records:
@@ -446,7 +450,7 @@ For overlapping records:
The result stores both original record IDs, shared resource keys, inferred interval, category, and Chinese explanation. The result stores both original record IDs, shared resource keys, inferred interval, category, and Chinese explanation.
- [ ] **Step 5: Implement deterministic replay decision** - [x] **Step 5: Implement deterministic replay decision**
Sort candidates once. Maintain an in-memory `ResourceLedger` with node and edge capacity 1. Replayable candidates become: Sort candidates once. Maintain an in-memory `ResourceLedger` with node and edge capacity 1. Replayable candidates become:
@@ -458,7 +462,7 @@ INSUFFICIENT_DATA when normalization blocked replay
Do not synthesize YIELD because no signed safe waiting points exist. Do not synthesize YIELD because no signed safe waiting points exist.
- [ ] **Step 6: Run analyzer tests and confirm GREEN** - [x] **Step 6: Run analyzer tests and confirm GREEN**
```bash ```bash
mvn -f javaTest/pom.xml -Dtest=HistoricalReplayAnalyzerTest test mvn -f javaTest/pom.xml -Dtest=HistoricalReplayAnalyzerTest test
@@ -466,7 +470,7 @@ mvn -f javaTest/pom.xml -Dtest=HistoricalReplayAnalyzerTest test
Expected: all analyzer tests pass. Expected: all analyzer tests pass.
- [ ] **Step 7: Commit** - [x] **Step 7: Commit**
```bash ```bash
git add javaTest/src/main/java/com/ft546/wcs/shadow/history javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalReplayAnalyzerTest.java git add javaTest/src/main/java/com/ft546/wcs/shadow/history javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalReplayAnalyzerTest.java
@@ -484,7 +488,7 @@ git commit -m "feat: replay inferred historical traffic windows"
- Consumes: `HistoricalQueueLoader`, `HistoricalQueueNormalizer`, `HistoricalReplayAnalyzer`. - Consumes: `HistoricalQueueLoader`, `HistoricalQueueNormalizer`, `HistoricalReplayAnalyzer`.
- Produces: Surefire-counted parameterized tests using original task and vehicle IDs. - Produces: Surefire-counted parameterized tests using original task and vehicle IDs.
- [ ] **Step 1: Write the real-data parameterized test** - [x] **Step 1: Write the real-data parameterized test**
```java ```java
@ParameterizedTest(name = "{index} task={0} agv={1} {2}->{3}") @ParameterizedTest(name = "{index} task={0} agv={1} {2}->{3}")
@@ -506,7 +510,7 @@ void replaysRealHistoryRecord(String taskNo, String agvId,
`realHistoryCases()` must expose every snapshot row, so Surefire counts at least 200 independent real-data cases. `realHistoryCases()` must expose every snapshot row, so Surefire counts at least 200 independent real-data cases.
- [ ] **Step 2: Add aggregate acceptance tests** - [x] **Step 2: Add aggregate acceptance tests**
Assert: Assert:
@@ -520,7 +524,7 @@ assertEquals(17136, dataset.getFullScanTotalRows());
If the fresh extraction count differs from 17,136, use the manifest's exact count and assert it matches `full-scan-summary.json`; do not hardcode a stale number. If the fresh extraction count differs from 17,136, use the manifest's exact count and assert it matches `full-scan-summary.json`; do not hardcode a stale number.
- [ ] **Step 3: Run real-data tests and confirm GREEN** - [x] **Step 3: Run real-data tests and confirm GREEN**
```bash ```bash
mvn -f javaTest/pom.xml -Dtest=HistoricalQueueDatabaseReplayTest test mvn -f javaTest/pom.xml -Dtest=HistoricalQueueDatabaseReplayTest test
@@ -528,7 +532,7 @@ mvn -f javaTest/pom.xml -Dtest=HistoricalQueueDatabaseReplayTest test
Expected: at least 200 parameterized cases plus aggregate checks; failures and errors are 0. Expected: at least 200 parameterized cases plus aggregate checks; failures and errors are 0.
- [ ] **Step 4: Run the existing synthetic safety matrix** - [x] **Step 4: Run the existing synthetic safety matrix**
```bash ```bash
mvn -f javaTest/pom.xml -Dtest=ShadowSimulationDataTest test mvn -f javaTest/pom.xml -Dtest=ShadowSimulationDataTest test
@@ -536,7 +540,7 @@ mvn -f javaTest/pom.xml -Dtest=ShadowSimulationDataTest test
Expected: 60 tests, failures 0, errors 0. Expected: 60 tests, failures 0, errors 0.
- [ ] **Step 5: Commit** - [x] **Step 5: Commit**
```bash ```bash
git add javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueDatabaseReplayTest.java git add javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueDatabaseReplayTest.java
@@ -556,7 +560,7 @@ git commit -m "test: replay real FT546 queue history"
- Produces: `void HistoricalReplayReportWriter.write(Path outputDir, HistoricalQueueDataset dataset, List<HistoricalPath> paths, List<HistoricalReplayResult> results)`. - Produces: `void HistoricalReplayReportWriter.write(Path outputDir, HistoricalQueueDataset dataset, List<HistoricalPath> paths, List<HistoricalReplayResult> results)`.
- CLI usage: `HistoricalReplayApplication <snapshotDir> <outputDir>`. - CLI usage: `HistoricalReplayApplication <snapshotDir> <outputDir>`.
- [ ] **Step 1: Write a failing writer test inside `HistoricalReplayAnalyzerTest`** - [x] **Step 1: Write a failing writer test inside `HistoricalReplayAnalyzerTest`**
```java ```java
@TempDir Path temp; @TempDir Path temp;
@@ -575,7 +579,7 @@ void writesReadableEvidenceWithOriginalIdsAndBoundaryText() throws Exception {
} }
``` ```
- [ ] **Step 2: Run the writer test and confirm RED** - [x] **Step 2: Run the writer test and confirm RED**
```bash ```bash
mvn -f javaTest/pom.xml -Dtest=HistoricalReplayAnalyzerTest#writesReadableEvidenceWithOriginalIdsAndBoundaryText test mvn -f javaTest/pom.xml -Dtest=HistoricalReplayAnalyzerTest#writesReadableEvidenceWithOriginalIdsAndBoundaryText test
@@ -583,7 +587,7 @@ mvn -f javaTest/pom.xml -Dtest=HistoricalReplayAnalyzerTest#writesReadableEviden
Expected: compilation failure because writer does not exist. Expected: compilation failure because writer does not exist.
- [ ] **Step 3: Implement CSV and JSON evidence** - [x] **Step 3: Implement CSV and JSON evidence**
`replay-results.csv` columns: `replay-results.csv` columns:
@@ -600,7 +604,7 @@ GUID,车辆,任务号,问题类型,原始路径,是否影响回放,处理方式
CSV must use RFC 4180 quoting and UTF-8 BOM so Excel opens Chinese text correctly. CSV must use RFC 4180 quoting and UTF-8 BOM so Excel opens Chinese text correctly.
- [ ] **Step 4: Implement summary and SHA manifest** - [x] **Step 4: Implement summary and SHA manifest**
`summary.json` includes: `summary.json` includes:
@@ -614,7 +618,7 @@ boundary="不是现场 SHADOW 验收"
`manifest.sha256` covers every generated file except itself, sorted by filename. `manifest.sha256` covers every generated file except itself, sorted by filename.
- [ ] **Step 5: Implement CLI** - [x] **Step 5: Implement CLI**
```java ```java
public static void main(String[] args) throws Exception { public static void main(String[] args) throws Exception {
@@ -626,7 +630,7 @@ public static void main(String[] args) throws Exception {
} }
``` ```
- [ ] **Step 6: Document the workflow** - [x] **Step 6: Document the workflow**
Add to `javaTest/README.md`: Add to `javaTest/README.md`:
@@ -639,7 +643,7 @@ mvn exec:java \
Explain original IDs are retained and the report distinguishes database facts from inference. Explain original IDs are retained and the report distinguishes database facts from inference.
- [ ] **Step 7: Run writer tests and CLI** - [x] **Step 7: Run writer tests and CLI**
```bash ```bash
mvn -f javaTest/pom.xml -Dtest=HistoricalReplayAnalyzerTest test mvn -f javaTest/pom.xml -Dtest=HistoricalReplayAnalyzerTest test
@@ -650,7 +654,7 @@ mvn -f javaTest/pom.xml exec:java \
Expected: evidence files exist, hashes verify, and CLI exits 0. Expected: evidence files exist, hashes verify, and CLI exits 0.
- [ ] **Step 8: Commit** - [x] **Step 8: Commit**
```bash ```bash
git add javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayReportWriter.java javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayApplication.java javaTest/README.md git add javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayReportWriter.java javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayApplication.java javaTest/README.md
@@ -668,7 +672,7 @@ git commit -m "feat: report real history replay evidence"
- Consumes: verified `summary.json`, `replay-results.csv`, `data-quality-issues.csv`. - Consumes: verified `summary.json`, `replay-results.csv`, `data-quality-issues.csv`.
- Produces: one workbook with sheets `测试总览`, `真实任务明细`, `潜在冲突`, `数据问题`, `来源与口径`. - Produces: one workbook with sheets `测试总览`, `真实任务明细`, `潜在冲突`, `数据问题`, `来源与口径`.
- [ ] **Step 1: Run a fresh full Maven verification** - [x] **Step 1: Run a fresh full Maven verification**
```bash ```bash
mvn -f javaTest/pom.xml clean test mvn -f javaTest/pom.xml clean test
@@ -676,11 +680,11 @@ mvn -f javaTest/pom.xml clean test
Expected: build success, every original test plus at least 200 real-history parameterized cases, failures 0, errors 0. Expected: build success, every original test plus at least 200 real-history parameterized cases, failures 0, errors 0.
- [ ] **Step 2: Generate fresh evidence** - [x] **Step 2: Generate fresh evidence**
Use a run ID containing the current date/time and Git short SHA. Never overwrite an earlier run. Use a run ID containing the current date/time and Git short SHA. Never overwrite an earlier run.
- [ ] **Step 3: Build the workbook with `@oai/artifact-tool`** - [x] **Step 3: Build the workbook with `@oai/artifact-tool`**
Workbook requirements: Workbook requirements:
@@ -691,7 +695,7 @@ Workbook requirements:
- One compact category-distribution chart. - One compact category-distribution chart.
- Amber boundary banner: “真实历史队列离线回放,不是现场 SHADOW 验收”. - Amber boundary banner: “真实历史队列离线回放,不是现场 SHADOW 验收”.
- [ ] **Step 4: Verify workbook values and formulas** - [x] **Step 4: Verify workbook values and formulas**
Inspect: Inspect:
@@ -709,15 +713,15 @@ Scan for:
Expected: no formula errors; KPI counts reconcile to JSON/CSV. Expected: no formula errors; KPI counts reconcile to JSON/CSV.
- [ ] **Step 5: Render and visually inspect every sheet** - [x] **Step 5: Render and visually inspect every sheet**
Check Chinese text, task numbers, vehicle IDs, full paths, chart, wrapped reason text, and boundary banner. Fix clipping before export. Check Chinese text, task numbers, vehicle IDs, full paths, chart, wrapped reason text, and boundary banner. Fix clipping before export.
- [ ] **Step 6: Verify manifest** - [x] **Step 6: Verify manifest**
Recalculate SHA-256 for all run-directory evidence except `manifest.sha256` and compare line-by-line. Recalculate SHA-256 for all run-directory evidence except `manifest.sha256` and compare line-by-line.
- [ ] **Step 7: Commit implementation and tests** - [x] **Step 7: Commit implementation and tests**
Do not commit generated `outputs/` evidence unless repository policy explicitly tracks evidence. Confirm: Do not commit generated `outputs/` evidence unless repository policy explicitly tracks evidence. Confirm:
@@ -732,14 +736,14 @@ Expected: source/resources/docs changes committed; generated output remains an e
## Final Verification Checklist ## Final Verification Checklist
- [ ] SQL operations were read-only. - [x] SQL operations were read-only.
- [ ] Full database aggregate scan row count is captured. - [x] Full database aggregate scan row count is captured.
- [ ] Snapshot contains at least 200 original records and 3 original AGV IDs. - [x] Snapshot contains at least 200 original records and 3 original AGV IDs.
- [ ] Original task, vehicle, site, and path values are visible. - [x] Original task, vehicle, site, and path values are visible.
- [ ] Missing/unknown data is fail-closed. - [x] Missing/unknown data is fail-closed.
- [ ] Replay is deterministic. - [x] Replay is deterministic.
- [ ] Existing 60 synthetic safety cases still pass. - [x] Existing 60 synthetic safety cases still pass.
- [ ] Full Maven suite passes with failures/errors 0. - [x] Full Maven suite passes with failures/errors 0.
- [ ] Excel totals reconcile to machine-readable evidence. - [x] Excel totals reconcile to machine-readable evidence.
- [ ] Report clearly states it is not现场 SHADOW/HIL/ACTIVE evidence. - [x] Report clearly states it is not现场 SHADOW/HIL/ACTIVE evidence.
- [ ] `ruoyi-*` production code is unchanged. - [x] `ruoyi-*` production code is unchanged.

View File

@@ -11,20 +11,52 @@ public final class HistoricalQueueDataset {
private final Set<String> mapSites; private final Set<String> mapSites;
private final int manifestSampleRows; private final int manifestSampleRows;
private final int fullScanTotalRows; private final int fullScanTotalRows;
private final String database;
private final String sourceTable;
private final String extractedAt; private final String extractedAt;
private final String minCreateDate;
private final String maxCreateDate;
private final String minFinishDate;
private final String maxFinishDate;
private final int missingPathRows;
private final int missingIntervalRows;
private final int reversedIntervalRows;
public HistoricalQueueDataset(List<HistoricalQueueRecord> records, Set<String> mapSites, public HistoricalQueueDataset(List<HistoricalQueueRecord> records, Set<String> mapSites,
int manifestSampleRows, int fullScanTotalRows, String extractedAt) { int manifestSampleRows, int fullScanTotalRows,
String database, String sourceTable, String extractedAt,
String minCreateDate, String maxCreateDate,
String minFinishDate, String maxFinishDate,
int missingPathRows, int missingIntervalRows,
int reversedIntervalRows) {
this.records = Collections.unmodifiableList(new ArrayList<HistoricalQueueRecord>(records)); this.records = Collections.unmodifiableList(new ArrayList<HistoricalQueueRecord>(records));
this.mapSites = Collections.unmodifiableSet(new LinkedHashSet<String>(mapSites)); this.mapSites = Collections.unmodifiableSet(new LinkedHashSet<String>(mapSites));
this.manifestSampleRows = manifestSampleRows; this.manifestSampleRows = manifestSampleRows;
this.fullScanTotalRows = fullScanTotalRows; this.fullScanTotalRows = fullScanTotalRows;
this.database = database;
this.sourceTable = sourceTable;
this.extractedAt = extractedAt; this.extractedAt = extractedAt;
this.minCreateDate = minCreateDate;
this.maxCreateDate = maxCreateDate;
this.minFinishDate = minFinishDate;
this.maxFinishDate = maxFinishDate;
this.missingPathRows = missingPathRows;
this.missingIntervalRows = missingIntervalRows;
this.reversedIntervalRows = reversedIntervalRows;
} }
public List<HistoricalQueueRecord> getRecords() { return records; } public List<HistoricalQueueRecord> getRecords() { return records; }
public Set<String> getMapSites() { return mapSites; } public Set<String> getMapSites() { return mapSites; }
public int getManifestSampleRows() { return manifestSampleRows; } public int getManifestSampleRows() { return manifestSampleRows; }
public int getFullScanTotalRows() { return fullScanTotalRows; } public int getFullScanTotalRows() { return fullScanTotalRows; }
public String getDatabase() { return database; }
public String getSourceTable() { return sourceTable; }
public String getExtractedAt() { return extractedAt; } public String getExtractedAt() { return extractedAt; }
public String getMinCreateDate() { return minCreateDate; }
public String getMaxCreateDate() { return maxCreateDate; }
public String getMinFinishDate() { return minFinishDate; }
public String getMaxFinishDate() { return maxFinishDate; }
public int getMissingPathRows() { return missingPathRows; }
public int getMissingIntervalRows() { return missingIntervalRows; }
public int getReversedIntervalRows() { return reversedIntervalRows; }
} }

View File

@@ -44,12 +44,21 @@ public final class HistoricalQueueLoader {
if (rows.size() < 200) { if (rows.size() < 200) {
throw new IllegalArgumentException("history sample must contain at least 200 rows"); throw new IllegalArgumentException("history sample must contain at least 200 rows");
} }
int totalRows = MAPPER.readTree(summaryBytes).path("total_rows").asInt(); JsonNode scanSummary = MAPPER.readTree(summaryBytes);
int totalRows = scanSummary.path("total_rows").asInt();
if (totalRows != manifest.path("totalRows").asInt()) { if (totalRows != manifest.path("totalRows").asInt()) {
throw new IllegalArgumentException("history full scan row count mismatch"); throw new IllegalArgumentException("history full scan row count mismatch");
} }
return new HistoricalQueueDataset(rows, sites, sampleRows, totalRows, return new HistoricalQueueDataset(rows, sites, sampleRows, totalRows,
manifest.path("extractedAt").asText()); manifest.path("database").asText(), manifest.path("table").asText(),
manifest.path("extractedAt").asText(),
scanSummary.path("min_create_date").asText(),
scanSummary.path("max_create_date").asText(),
scanSummary.path("min_finish_date").asText(),
scanSummary.path("max_finish_date").asText(),
scanSummary.path("missing_path_rows").asInt(),
scanSummary.path("missing_interval_rows").asInt(),
scanSummary.path("reversed_interval_rows").asInt());
} catch (IOException ex) { } catch (IOException ex) {
throw new IllegalArgumentException("cannot load history snapshot", ex); throw new IllegalArgumentException("cannot load history snapshot", ex);
} }
@@ -73,7 +82,11 @@ public final class HistoricalQueueLoader {
} }
public static void verifySha256(String expected, byte[] bytes) { public static void verifySha256(String expected, byte[] bytes) {
String actual = sha256(bytes); byte[] canonicalBytes = new String(bytes, StandardCharsets.UTF_8)
.replace("\r\n", "\n")
.replace('\r', '\n')
.getBytes(StandardCharsets.UTF_8);
String actual = sha256(canonicalBytes);
if (expected == null || !expected.equalsIgnoreCase(actual)) { if (expected == null || !expected.equalsIgnoreCase(actual)) {
throw new IllegalArgumentException("history snapshot sha256 mismatch"); throw new IllegalArgumentException("history snapshot sha256 mismatch");
} }

View File

@@ -21,7 +21,7 @@ public final class HistoricalReplayAnalyzer {
List<HistoricalPath> ordered = new ArrayList<HistoricalPath>(input); List<HistoricalPath> ordered = new ArrayList<HistoricalPath>(input);
Collections.sort(ordered, stableOrder()); Collections.sort(ordered, stableOrder());
List<HistoricalReplayResult> results = new ArrayList<HistoricalReplayResult>(); List<HistoricalReplayResult> results = new ArrayList<HistoricalReplayResult>();
List<HistoricalPath> accepted = new ArrayList<HistoricalPath>(); List<HistoricalPath> priorReplayable = new ArrayList<HistoricalPath>();
for (HistoricalPath path : ordered) { for (HistoricalPath path : ordered) {
if (!path.isReplayable()) { if (!path.isReplayable()) {
results.add(result(path, HistoricalReplayCategory.INSUFFICIENT_DATA, "INSUFFICIENT_DATA", results.add(result(path, HistoricalReplayCategory.INSUFFICIENT_DATA, "INSUFFICIENT_DATA",
@@ -29,7 +29,7 @@ public final class HistoricalReplayAnalyzer {
continue; continue;
} }
HistoricalReplayResult conflict = null; HistoricalReplayResult conflict = null;
for (HistoricalPath prior : accepted) { for (HistoricalPath prior : priorReplayable) {
if (!overlaps(prior, path)) continue; if (!overlaps(prior, path)) continue;
if (same(path.getRecord().getAgvId(), prior.getRecord().getAgvId())) { if (same(path.getRecord().getAgvId(), prior.getRecord().getAgvId())) {
conflict = result(path, HistoricalReplayCategory.SAME_VEHICLE_OVERLAP, "HOLD", prior, conflict = result(path, HistoricalReplayCategory.SAME_VEHICLE_OVERLAP, "HOLD", prior,
@@ -50,12 +50,12 @@ public final class HistoricalReplayAnalyzer {
} }
} }
if (conflict == null) { if (conflict == null) {
accepted.add(path);
results.add(result(path, HistoricalReplayCategory.NO_CONFLICT, "GRANT", null, results.add(result(path, HistoricalReplayCategory.NO_CONFLICT, "GRANT", null,
Collections.<String>emptyList(), "按历史生命周期推导窗口未发现资源冲突")); Collections.<String>emptyList(), "按历史生命周期推导窗口未发现资源冲突"));
} else { } else {
results.add(conflict); results.add(conflict);
} }
priorReplayable.add(path);
} }
return Collections.unmodifiableList(results); return Collections.unmodifiableList(results);
} }

View File

@@ -14,6 +14,8 @@ import java.util.EnumMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
public final class HistoricalReplayReportWriter { public final class HistoricalReplayReportWriter {
private static final ObjectMapper JSON = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); private static final ObjectMapper JSON = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
@@ -27,17 +29,45 @@ public final class HistoricalReplayReportWriter {
summary.put("mode", "REAL_HISTORY_OFFLINE_REPLAY"); summary.put("mode", "REAL_HISTORY_OFFLINE_REPLAY");
summary.put("title", "真实历史队列离线回放"); summary.put("title", "真实历史队列离线回放");
summary.put("boundary", "不是现场 SHADOW 验收"); summary.put("boundary", "不是现场 SHADOW 验收");
summary.put("database", dataset.getDatabase());
summary.put("table", dataset.getSourceTable());
summary.put("extractedAt", dataset.getExtractedAt());
summary.put("sourceRowCount", dataset.getFullScanTotalRows()); summary.put("sourceRowCount", dataset.getFullScanTotalRows());
summary.put("sampleRowCount", dataset.getRecords().size()); summary.put("sampleRowCount", dataset.getRecords().size());
summary.put("mapSiteCount", dataset.getMapSites().size()); summary.put("mapSiteCount", dataset.getMapSites().size());
summary.put("minCreateDate", dataset.getMinCreateDate());
summary.put("maxCreateDate", dataset.getMaxCreateDate());
summary.put("minFinishDate", dataset.getMinFinishDate());
summary.put("maxFinishDate", dataset.getMaxFinishDate());
summary.put("missingPathRows", dataset.getMissingPathRows());
summary.put("missingIntervalRows", dataset.getMissingIntervalRows());
summary.put("reversedIntervalRows", dataset.getReversedIntervalRows());
Set<String> vehicles = new TreeSet<String>();
Set<String> taskTypes = new TreeSet<String>();
for (HistoricalQueueRecord record : dataset.getRecords()) {
if (record.getAgvId() != null) vehicles.add(record.getAgvId());
if (record.getTaskType() != null) taskTypes.add(record.getTaskType());
}
summary.put("vehicles", vehicles);
summary.put("taskTypes", taskTypes);
summary.put("replayableRows", paths.stream().filter(HistoricalPath::isReplayable).count()); summary.put("replayableRows", paths.stream().filter(HistoricalPath::isReplayable).count());
summary.put("qualityIssueRows", paths.stream().filter(p -> !p.getIssues().isEmpty()).count()); summary.put("qualityIssueRows", paths.stream().filter(p -> !p.getIssues().isEmpty()).count());
Map<String, Integer> issueCounts = new LinkedHashMap<String, Integer>();
for (HistoricalPath path : paths) {
for (String issue : path.getIssues()) {
String type = issue.contains(":") ? issue.substring(0, issue.indexOf(':')) : issue;
issueCounts.put(type, issueCounts.containsKey(type) ? issueCounts.get(type) + 1 : 1);
}
}
summary.put("qualityIssueCounts", issueCounts);
Map<String, Integer> counts = new LinkedHashMap<String, Integer>(); Map<String, Integer> counts = new LinkedHashMap<String, Integer>();
for (HistoricalReplayCategory category : HistoricalReplayCategory.values()) counts.put(category.name(), 0); for (HistoricalReplayCategory category : HistoricalReplayCategory.values()) counts.put(category.name(), 0);
for (HistoricalReplayResult result : results) { for (HistoricalReplayResult result : results) {
counts.put(result.getCategory().name(), counts.get(result.getCategory().name()) + 1); counts.put(result.getCategory().name(), counts.get(result.getCategory().name()) + 1);
} }
summary.put("categoryCounts", counts); summary.put("categoryCounts", counts);
summary.put("replayGrant", results.stream().filter(r -> "GRANT".equals(r.getDecision())).count());
summary.put("replayHold", results.stream().filter(r -> "HOLD".equals(r.getDecision())).count());
summary.put("deterministic", true); summary.put("deterministic", true);
JSON.writeValue(outputDir.resolve("summary.json").toFile(), summary); JSON.writeValue(outputDir.resolve("summary.json").toFile(), summary);
writeManifest(outputDir); writeManifest(outputDir);

View File

@@ -22,6 +22,11 @@ class HistoricalQueueLoaderTest {
assertFalse(data.getRecords().get(0).getTaskNo().isEmpty()); assertFalse(data.getRecords().get(0).getTaskNo().isEmpty());
assertFalse(data.getRecords().get(0).getAgvId().isEmpty()); assertFalse(data.getRecords().get(0).getAgvId().isEmpty());
assertTrue(data.getMapSites().size() > 1000); assertTrue(data.getMapSites().size() > 1000);
assertEquals("FT546-wcs", data.getDatabase());
assertEquals("dbo.wcs_srgv_queue_finish", data.getSourceTable());
assertEquals("2026-03-01 10:18:24", data.getMinCreateDate());
assertEquals("2026-07-21 14:26:51", data.getMaxFinishDate());
assertEquals(5193, data.getMissingPathRows());
} }
@Test @Test
@@ -30,4 +35,14 @@ class HistoricalQueueLoaderTest {
HistoricalQueueLoader.verifySha256( HistoricalQueueLoader.verifySha256(
"wrong", "abc".getBytes(StandardCharsets.UTF_8))); "wrong", "abc".getBytes(StandardCharsets.UTF_8)));
} }
@Test
void verifiesTextHashIndependentlyOfLineEndingStyle() {
String canonicalSha256 =
"911169ddaaf146aff539f58c26c489af3b892dff0fe283c1c264c65ae5aa59a2";
HistoricalQueueLoader.verifySha256(
canonicalSha256, "a\nb\n".getBytes(StandardCharsets.UTF_8));
HistoricalQueueLoader.verifySha256(
canonicalSha256, "a\r\nb\r\n".getBytes(StandardCharsets.UTF_8));
}
} }

View File

@@ -1,5 +1,7 @@
package com.ft546.wcs.shadow; package com.ft546.wcs.shadow;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ft546.wcs.shadow.history.HistoricalPath; import com.ft546.wcs.shadow.history.HistoricalPath;
import com.ft546.wcs.shadow.history.HistoricalQueueDataset; import com.ft546.wcs.shadow.history.HistoricalQueueDataset;
import com.ft546.wcs.shadow.history.HistoricalQueueLoader; import com.ft546.wcs.shadow.history.HistoricalQueueLoader;
@@ -46,6 +48,18 @@ class HistoricalReplayAnalyzerTest {
result.getCategory() == HistoricalReplayCategory.POTENTIAL_OPPOSITE_EDGE)); result.getCategory() == HistoricalReplayCategory.POTENTIAL_OPPOSITE_EDGE));
} }
@Test
void heldPathStillParticipatesInLaterConflictDetection() {
List<HistoricalReplayResult> results = analyzer.analyze(Arrays.asList(
path("G1", "T1", "V1", "A,B,", "10:00:00", "10:04:00"),
path("G2", "T2", "V2", "B,C,", "10:00:30", "10:03:30"),
path("G3", "T3", "V3", "C,D,", "10:01:00", "10:03:00")));
assertEquals(HistoricalReplayCategory.POTENTIAL_NODE_CONFLICT,
results.get(2).getCategory());
assertEquals("T2", results.get(2).getRelatedTaskNo());
}
@Test @Test
void identicalInputProducesIdenticalResults() { void identicalInputProducesIdenticalResults() {
List<HistoricalPath> sample = Arrays.asList( List<HistoricalPath> sample = Arrays.asList(
@@ -67,10 +81,22 @@ class HistoricalReplayAnalyzerTest {
StandardCharsets.UTF_8); StandardCharsets.UTF_8);
String summary = new String(Files.readAllBytes(temp.resolve("summary.json")), String summary = new String(Files.readAllBytes(temp.resolve("summary.json")),
StandardCharsets.UTF_8); StandardCharsets.UTF_8);
JsonNode summaryJson = new ObjectMapper().readTree(summary);
assertTrue(csv.contains(dataset.getRecords().get(0).getTaskNo())); assertTrue(csv.contains(dataset.getRecords().get(0).getTaskNo()));
assertTrue(csv.contains(dataset.getRecords().get(0).getAgvId())); assertTrue(csv.contains(dataset.getRecords().get(0).getAgvId()));
assertTrue(summary.contains("真实历史队列离线回放")); assertTrue(summary.contains("真实历史队列离线回放"));
assertTrue(summary.contains("不是现场 SHADOW 验收")); assertTrue(summary.contains("不是现场 SHADOW 验收"));
assertEquals("FT546-wcs", summaryJson.path("database").asText());
assertEquals("dbo.wcs_srgv_queue_finish", summaryJson.path("table").asText());
assertEquals(dataset.getExtractedAt(), summaryJson.path("extractedAt").asText());
assertEquals("2026-03-01 10:18:24", summaryJson.path("minCreateDate").asText());
assertEquals(3, summaryJson.path("vehicles").size());
assertEquals(3, summaryJson.path("taskTypes").size());
assertEquals(2, summaryJson.path("qualityIssueCounts").path("UNKNOWN_MAP_SITE").asInt());
assertEquals(dataset.getRecords().size(),
summaryJson.path("replayGrant").asInt()
+ summaryJson.path("replayHold").asInt()
+ summaryJson.path("categoryCounts").path("INSUFFICIENT_DATA").asInt());
assertTrue(Files.size(temp.resolve("manifest.sha256")) > 0); assertTrue(Files.size(temp.resolve("manifest.sha256")) > 0);
} }