diff --git a/REPORT.md b/REPORT.md index 2956f63c..6dbb8c29 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,4 +1,13 @@ # REPORT +## [2026-09-19] 두 회차를 비교할 수 있게 «순서»를 고정했다 (2026-09-19-partial-clone-blob-vs-absence-p0) +- 무엇을: 채택 제안 `2026-09-19-partial-clone-blob-vs-absence#p0`. `scripts/check-merge-dropped-symbols.py` 가 **같은 결과를 회차마다 다른 순서로** 찍어, 두 회차를 diff 하면 ★**없는 차이가 보였다**. ★**코드 1줄**(+주석 8줄). +- ★**재현**: `origin/main` 판본 · 범위 `e53b2142^..e53b2142`(8머지 · 6드롭 · rc 1) · `PYTHONHASHSEED=random` **10회** → ★**서로 다른 순서 «2종»**(6회/4회). 차이는 ★**경로 블록의 선후 하나뿐**이다. +- ★★**제안의 «진단»은 정확하지 않았다 — 고치는 자리가 달라진다.** 제안은 「findings 가 set 에 모인다」고 했는데 ⑴`findings` 는 **list** 이고 ⑵한 경로 «안»의 이름은 **이미 `sorted()`** 였다. 파일의 set **4개**를 전수로 보면 출력 순서에 닿는 것은 ★**`changed`(경로 집합) 하나뿐**이다. ⇒ 「print site 에서 정렬」은 **우연히** 맞는 처방이었다. +- ★**그래서 «출처»에서 정렬했다** — `for path in sorted(filter(None, changed))`. `findings` 는 `check()` 가 **반환**도 하므로, `main()` 에서 정렬하면 **찍는 것만** 결정적이고 **반환값은 여전히** 해시 순서다. +- ★**양방향(제품 호출부 · 사본 아님)**: 전 **2종** ↔ 후 **1종** ↔ 되돌리면 **2종** ↔ 복원 **1종**. ★**찾은 것은 그대로다**: 출력 **15줄** 동일 · 집합으로 정렬하면 **완전 일치** · rc **1** 불변. +- ★★**대가 — 「잃는 것이 없다」가 아니다**: ⒜순회 순서라는 성질을 **없앴다**(읽는 곳은 없다) ⒝★**아무도 잠그지 않는다.** 이 repo 엔 `scripts/` 용 **테스트 하네스가 없다**(`test*.py` **0개**) — 비결정성을 **되돌려 놓고 재니** 파이썬 검사기 **4종 전건 rc 0** · `cargo fmt` **rc 0**. ⇒ ★**이 계급에 대한 그물은 «0»이고, 이 수정은 규칙이 아니라 습관이다.** 후속 제안으로 남겼다(하네스는 1줄보다 큰 결정이다). +- 후속 추천: `docs/worklog/2026-09-19-merge-drops-deterministic-order.{md,json}` — 「검사기 출력 결정성을 잠가라」(effort M). + ## [2026-09-19] 적재 가능 집합을 «로더에서 읽을까» — ★**아니다, 재유도를 유지한다**(rustjava-loadable-set-from-loader-vs-rederive-decision) - 무엇을: 채택 제안 `2026-09-18-named-exception-classes-are-loadable#p2`(worklog json 기록). ★**순수 결정 회차 — `.rs` 0줄 · `scripts/` 0줄.** 산출 = `docs/loadable-set-source-of-truth.md`(선례 = `docs/test-data-target-policy.md`). - ★★**전제부터 확인했다 — 「4결함 중 3이 재유도」는 «참»이다.** 산문이 아니라 **고침 커밋 `89c2e83c` 에서** 갈랐다: ⑴`as_proto` 전용 → `list_proto` 3건 누락 ⑵한 `impl` 의 첫 `name:` 오귀속 ⑶짧은 이름 키 충돌 = **재유도(loadable) 3건** · ⑷줄 단위 스캔이 rustfmt 가 쪼갠 34건 누락 = ★**호출부 스캔(named) 1건**. diff --git a/STATE.md b/STATE.md index 04340f30..1f04141f 100644 --- a/STATE.md +++ b/STATE.md @@ -7,6 +7,11 @@ (둘 다 이것보다 오래됐고 MERGEABLE/CONFLICTING 처분이 이미 걸려 있다). 겹침은 전부 **append 형 합집합**이라 해소는 기계적이다) ## 완료 +- [2026-09-19-partial-clone-blob-vs-absence-p0] ★★**`check-merge-dropped-symbols.py` 의 출력 순서를 고정했다 — 두 회차를 diff 할 수 있다.** 채택 제안 `2026-09-19-partial-clone-blob-vs-absence#p0`. ★**코드 1줄**(+주석 8줄) · `.rs` 0줄. + ★**재현**: `origin/main` 판본 · `PYTHONHASHSEED=random` **10회** → 순서 **2종**(같은 6건) ⇒ 없는 차이가 diff 에 보였다. + ★★**제안 진단은 부정확**: `findings` 는 set 이 아니라 **list** 이고 이름은 이미 정렬돼 있었다 — set 4개 중 출력에 닿는 것은 ★**`changed`(경로) 하나**다. ⇒ 「print site」가 아니라 ★**출처에서** 정렬했다(`check()` 의 **반환값**도 결정적이어야 하므로). + ★**양방향**: 2종 ↔ **1종** ↔ 되돌리면 2종. ★찾은 것 불변(15줄 · 집합 일치 · rc 1). + ★**대가**: 아무도 잠그지 않는다 — `scripts/` 테스트 하네스 **0** · 비결정성을 넣어도 파이썬 검사기 **4종 rc 0** · fmt **rc 0** ⇒ ★**그물 «0»**. 후속 제안으로 남겼다. - [rustjava-loadable-set-from-loader-vs-rederive-decision] ★★**적재 가능 집합은 «재유도»를 유지한다 — 로더에서 읽지 않는다.** 채택 제안 `2026-09-18-named-exception-classes-are-loadable#p2`. ★**순수 결정 · 코드 0줄** · 산출 = `docs/loadable-set-source-of-truth.md`. ★**전제 확인**: 「4중 3이 재유도」는 **참**(고침 커밋 `89c2e83c` 에서 갈랐다 — loadable 3 · named 1). ★★**그 1건이 결정한다**: `named`(코드가 무엇을 넘기는가)는 **로더가 답할 수 없다** ⇒ 로더 읽기는 **파서 하나를 없앨 뿐 파싱을 못 없앤다**(그 절반에서 형제 회차가 ★**4시간 31분** 뒤에 또 잡았다 — ★**「다른 함수 8종 41자리」**를 쓸어담는 앵커 함정이고, 세면 **33** · 맞는 답은 **0** 이다). diff --git a/docs/worklog/2026-09-19-merge-drops-deterministic-order.json b/docs/worklog/2026-09-19-merge-drops-deterministic-order.json new file mode 100644 index 00000000..ffa5e673 --- /dev/null +++ b/docs/worklog/2026-09-19-merge-drops-deterministic-order.json @@ -0,0 +1,41 @@ +{ + "date": "2026-09-19", + "taskId": "2026-09-19-partial-clone-blob-vs-absence-p0", + "summary": "check-merge-dropped-symbols.py printed the same findings in a different order on different runs, so diffing two runs showed differences that were not there. One line: iterate the changed-paths set in sorted order.", + "decision": "Sort at the source (the `changed` set iteration in check()) rather than at the print site in main(), so that what check() *returns* is ordered too, not only what main() happens to print.", + "measurements": { + "range_used": "e53b2142^..e53b2142 (8 merges, 6 dropped definitions, rc 1)", + "before_distinct_orderings_over_10_runs": 2, + "after_distinct_orderings_over_10_runs": 1, + "mutation_distinct_orderings_over_10_runs": 2, + "findings_unchanged": "15 output lines before and after; identical as a set; rc 1 both", + "nondeterminism_sources_audited": 4, + "nondeterminism_sources_reaching_output_order": 1, + "note_on_the_proposal_text": "The proposal said findings are 'accumulated in a set'. They are not -- `findings` is a list, and names within one path were already sorted(). The single real source is `changed`, a set of paths." + }, + "verification": { + "bidirectional_mutation": "product call site, not a copy: removing sorted() from scripts/check-merge-dropped-symbols.py takes 10 runs from 1 distinct ordering to 2; restoring it returns to 1", + "existing_guard_measured_under_the_mutation": "zero: all 4 python checkers rc 0 and cargo fmt --check rc 0 with the nondeterminism present; clippy and cargo test were not re-run because they do not read this script's output", + "dod": "all 9 DoD commands rc 0" + }, + "changes": [ + "scripts/check-merge-dropped-symbols.py: `for path in filter(None, changed)` -> `for path in sorted(filter(None, changed))`, plus an 8-line comment recording why and the measurement" + ], + "issues": [ + "Nothing locks the ordering. There is no test harness for the scripts/ checkers in this repo, so a future edit can reintroduce the same nondeterminism silently. Recorded as proposal p0 rather than built, because adding one would be a larger round than the fix." + ], + "adoptedProposals": [ + "2026-09-19-partial-clone-blob-vs-absence#p0" + ], + "proposals": [ + { + "title": "Lock the checkers' output determinism so it cannot silently come back", + "plainSummary": "The ordering was just fixed, but nothing checks that it stays fixed, so the same problem can return without anyone noticing.", + "userBenefit": "A future edit that reintroduces run-to-run variation fails in CI instead of costing the next round the same wasted comparison this one paid for.", + "why": "Measured this round: removing the one-word fix from scripts/check-merge-dropped-symbols.py takes the same range from 1 distinct ordering over 10 runs back to 2, and every repo axis stays green -- fmt, clippy, cargo test and all four python checkers pass with the nondeterminism present. So the guard that exists today is zero. The repo has no test harness for scripts/ at all (0 test*.py files), which is why this was not simply added here: it needs a harness decision, not a line.", + "tradeoff": "A harness for four standalone scripts is real weight -- a new CI job and a place for it to live -- and two runs under different PYTHONHASHSEED values is a slow way to assert an invariant a reader can see in one line. The cheaper variant is to assert sortedness directly rather than re-running, which does not catch a second source appearing elsewhere. Leaving it means the fix is a habit, not a rule.", + "effort": "M", + "target": "scripts/ (all four checkers), .github/workflows/rust.yml" + } + ] +} diff --git a/docs/worklog/2026-09-19-merge-drops-deterministic-order.md b/docs/worklog/2026-09-19-merge-drops-deterministic-order.md new file mode 100644 index 00000000..0a4bdebc --- /dev/null +++ b/docs/worklog/2026-09-19-merge-drops-deterministic-order.md @@ -0,0 +1,72 @@ +# 2026-09-19 — `check-merge-dropped-symbols.py` now prints in a stable order + +Adopts `2026-09-19-partial-clone-blob-vs-absence#p0` ("Sort the checker's findings so two runs can +be compared"). + +## What was wrong + +The checker printed the same findings in a different order on different runs, so diffing two runs +showed differences that were not really there. Reproduced on `origin/main`'s own version, range +`e53b2142^..e53b2142` (8 merges, 6 dropped definitions, rc 1): **ten runs under +`PYTHONHASHSEED=random` produced exactly two distinct orderings**, 6 of one and 4 of the other. The +two differ only in which path's block comes first — `tests/test_class_format.rs` or +`test-data/src/indy/make_indy_fixtures.py`. + +## Where it actually came from — the proposal's diagnosis was close but not exact + +The proposal said findings are "accumulated in a set and printed in iteration order". They are not: +`findings` is a **list**, and the names within a single path were **already** `sorted()`. Auditing +all four sets in the file: + +| set | reaches output order? | +|---|---| +| `found` in `symbols()` | no — consumed by `sorted()` at the diff site | +| `names` in `excused()` | no — membership tests only (`name not in accounted`) | +| `theirs_symbols - symbols(...)` | no — already wrapped in `sorted()` | +| **`changed` in `check()`** | **yes** — a set of paths, iterated directly | + +So **one** of four candidate sources reaches the output, and it is the path iteration, not the +finding accumulation. The symptom the proposal described is real; the sentence naming its cause +is not, and a fix aimed literally at "the print site" would have worked by accident. + +## The change + +One line in `check()`: + +```python +for path in sorted(filter(None, changed)): +``` + +Sorted **at the source rather than at the print site**, because `findings` is also *returned* by +`check()`. Sorting in `main()` would order what gets printed and leave the return value still +dependent on hash order, which is a smaller fix wearing the same clothes. + +## Verification + +Bidirectional, in the product call site (not a copy of it): + +| version | distinct orderings / 10 runs | rc | +|---|---|---| +| `origin/main` (before) | **2** | 1 | +| with the fix | **1** | 1 | +| fix removed again (mutation) | **2** | 1 | +| restored | **1** | 1 | + +Findings themselves are untouched: 15 output lines before and after, identical when both are +sorted as sets, same rc. The fix changes order and nothing else. + +## What this costs + +- **The output no longer reflects traversal order.** Nothing reads it, so the practical cost is + zero, but it is a real property that was removed rather than nothing at all. +- **Nothing locks it.** This repo has **no test harness for `scripts/`** (0 `test*.py` files). + Measured with the nondeterminism deliberately put back: all four python checkers exit 0 + (`check-worklog-json`, `check-dod-ci-parity`, `check-named-exception-classes-are-loadable`, + `check-merge-dropped-symbols` itself) and `cargo fmt --check` exits 0. The two Rust axes not + re-run under the mutation — `clippy` and `cargo test` — do not read this script's output at all, + so the honest summary is that **the guard against this class of defect is zero**. This fix is a + habit, not a rule, and a later edit can undo it silently. That is the one genuine gap this round + leaves, and it is filed as the follow-up proposal rather than built, because a harness is a + larger decision than a one-line ordering fix. +- **Scope deliberately not widened.** The other three `scripts/` checkers were not audited for the + same class of defect; that is part of the same follow-up. diff --git a/scripts/check-merge-dropped-symbols.py b/scripts/check-merge-dropped-symbols.py index d038145a..e0d81acf 100755 --- a/scripts/check-merge-dropped-symbols.py +++ b/scripts/check-merge-dropped-symbols.py @@ -243,7 +243,15 @@ def check(merge): accounted = excused(merge) if excuses_everything(accounted): return [], 0 - for path in filter(None, changed): + # `sorted` so that two runs can be diffed. `changed` is a set of paths, and iterating it takes + # Python's per-process randomised string hash order: measured on origin/main's version over + # `e53b2142^..e53b2142`, ten runs under PYTHONHASHSEED=random printed the same six findings in + # two different orders, differing only in which path's block came first. That cost the round + # that found it real time -- a before/after diff read as a regression until the unchanged + # version was shown to disagree with itself. Names *within* a path were already sorted below, + # so the path order was the only axis left. Sorting here rather than at the print site fixes + # the order of what `check()` returns, not just what main() happens to print. + for path in sorted(filter(None, changed)): theirs_symbols = symbols(theirs, path) if theirs_symbols is None: continue