diff --git a/REPORT.md b/REPORT.md index db287e9b..dbb02acc 100644 --- a/REPORT.md +++ b/REPORT.md @@ -80,6 +80,19 @@ - 검증: DoD 9명령 · 아래 절. - ★후속 추천: **그 사각이 «제품인지 테스트인지»를 말할 것인가**(S). 상세 = `docs/worklog/2026-09-19-nonliteral-blind-spot-is-reported-not-gated.md`. +## [2026-09-19] 되유도를 «믿지 않고 단언한다» — 검사기가 자기 읽기를 스스로 증명한다(rustjava-assert-loadable-rederivation-did-not-come-up-short) +- 무엇을: 채택 제안 `2026-09-19-loadable-set-source-of-truth#p0`(worklog json 기록). ★**제안에 `how` 필드가 없다** — `why` 를 따르고 `tradeoff` 에서는 **의도적으로 갈렸다**(아래). +- ★**먼저 쟀다 — 지금 «적게 잡히는» 것이 있는가: 없다.** 같은 것을 네 가지로 센다: `_proto()` 출현 **268** · `_proto(),` 줄 **268** · `crate::classes::` **268** · `REGISTERED` 파싱 **268** · 해석된 고유 이름 **268**. + ★**단언이 «틀리게» 울 조건도 함께 쟀다**(이게 핵심이다): 한 줄에 등재 둘 **0** · 쉼표 없는 `_proto()` **0** · 주석 속 `_proto()` **0** · 중복 이름 **0**. + ⇒ ★**green 에서 시작하는 회귀 방어**이지 «현존 결함 수리»가 아니다 — 지어내지 않고 그대로 적는다. +- ★**지은 것 — 두 축, 둘 다 fail-closed**: ⑴**파싱 ↔ 증인**(`REGISTERED` 는 «의심 대상»이라 자기 매치를 세는 것은 증명이 아니다 ⇒ 등재가 **없이는 쓰일 수 없는 토큰**으로 두 번째로 센다) ⑵**등재 수 ↔ 고유 이름 수**(둘이 한 이름으로 접히면 해석이 틀린 것 — ★**접힌 쌍을 이름으로 찍는다**). **1파일 +38/−2.** +- ★★**양방향 축 — 제품 스크립트를 «실제로 있었던 결함»으로 되돌려 쟀다**: ⑴초판의 `as_proto` 전용 정규식 → ★**rc=2** 「265 registrations parsed but 268 proto calls」 ⑵초판의 짧은 이름 키 → ★**rc=2** 「268 registrations resolved to only 263 names」 + ★**`java/util/Formatter <- java::util::Formatter::as_proto(), java::util::logging::Formatter::as_proto()`** 를 포함해 다섯 쌍을 지목 · 원형상 **rc=0**. + ★**그 둘째 메시지가 이 회차의 요지다** — 게이트②가 «소스를 나란히 읽어» 찾아낸 그 충돌쌍을 ★**이제 스크립트가 말한다**. +- ★**브리프의 세 질문에 답한다**: ⒜**무엇과 비교하나** = ★**소스 자신**(같은 파일을 두 번째로 센다 · 저장된 기준선도, 직전 실행값도 아니다) ⒝**첫 실행·정당한 감소** = ★**애초에 생기지 않는다**(기억하는 수가 없다 — 클래스를 지우면 네 수가 «함께» 내려가 green 유지). ★이 형상을 고른 이유가 바로 그것이다 ⒞**죽는가 말하는가** = ★**죽는다(exit 2 «cannot measure»)**. 형제 회차는 「세어 찍고 절대 실패시키지 않는다」를 골랐고 ★**나는 갈렸다** — 그쪽은 «알려진 사각을 재는» 것이고 이쪽은 ★**검사기가 «자기 입력»을 잘못 읽는** 것이다. 이 파일은 이미 그 계급을 exit 2 로 다룬다(해석 불가 등재). +- ★**대가**: ⒜숫자 둘이 «참이어야» 한다 — `loader.rs` 가 등재 배열 «밖»에서 `as_proto()` 를 부르면 **정상 트리에서 red**(오늘은 268 전건이 등재다) ⒝★**«진짜» 중복 등재는 false red** 가 된다(오늘 0 · 메시지가 이름을 대지만 위험은 실재) ⒞★**바닥이지 증명이 아니다** — 틀리되 **서로 다른** 이름으로 매핑되면 268 을 유지하고 통과한다(실측된 거짓 초록 둘은 «과소계수» 계급이었다) ⒟비용 **유의차 없음**(전 6.47/8.01/4.57s ↔ 후 6.35/5.68/7.53s · 구간 겹침 · 부하가 커 편차가 효과보다 크다). +- ★**제안의 `tradeoff` 에서 갈렸다(의도)**: 제안은 「`loader.rs` 의 **텍스트 형태(한 줄 한 등재)** 에 묶인다」를 대가로 예고했다 ⇒ ★**줄이 아니라 «출현»을 세어 그 묶임을 없앴다**(줄 수와 오늘 동일 268 이라 잃는 것도 없다). +- 검증: DoD 9명령 · 아래 절. + ## [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 5cdb8e8b..27af920e 100644 --- a/STATE.md +++ b/STATE.md @@ -57,6 +57,13 @@ ★**축**: 제품 코드 주입 → **1→2**(파일:줄 지목) · 원복 → 1 · rc 양쪽 0 · 술어 민감도 **42**. ★**대가**: 찍힌 수는 무시할 수 있다 · 수는 술어만큼만 정확 · 제품/테스트 미구별(후속 카드). ★**회귀 둘을 스스로 만들고 재서 걷어냈다**(두 번 걷기 · 개행 인덱스) ⇒ 최종 비용 **유의차 없음**. +- [rustjava-assert-loadable-rederivation-did-not-come-up-short] ★★**되유도를 믿지 않고 «단언»한다 — 두 축 모두 fail-closed(exit 2).** 채택 제안 `2026-09-19-loadable-set-source-of-truth#p0`. ★**1파일 +38/−2.** + ★**먼저 쟀다**: 같은 것을 네 가지로 세어 **전부 268** · ★**틀리게 울 조건도 0**(한 줄 둘·쉼표 없음·주석 속·중복 이름) ⇒ **green 에서 시작하는 회귀 방어**다. + ★**두 축**: ⑴파싱 ↔ **독립 증인**(의심 대상의 자기 매치는 증명이 아니다) ⑵등재 수 ↔ 고유 이름 수(★접힌 쌍을 **이름으로** 찍는다). + ★**양방향**: 초판 결함 «둘»을 제품 스크립트에 되살려 각각 **rc=2**(265↔268 · 268→263 + 충돌쌍 지목) · 원형상 **rc=0**. + ★**브리프 3문**: 비교 대상 = **소스 자신**(기준선·직전값 아님) · 첫 실행/정당한 감소 = ★**생기지 않는다**(기억이 없다) · ★**죽는다(exit 2)** — 형제 회차의 「찍고 안 죽는다」와 **갈렸고 사유를 적었다**(사각 측정 ↔ 자기 입력 오독). + ★**대가**: 등재 배열 «밖» 호출이면 false red · 진짜 중복 등재도 false red(오늘 0) · ★**바닥이지 증명 아님**(다른 이름으로 틀리면 통과) · 비용 유의차 없음. + ★**제안 `tradeoff` 와 갈렸다**: 「줄 형태에 묶인다」를 ★**출현 계수**로 없앴다(오늘 줄 수와 동일). - [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-assert-rederivation-did-not-come-up-short.json b/docs/worklog/2026-09-19-assert-rederivation-did-not-come-up-short.json new file mode 100644 index 00000000..ee4c19c3 --- /dev/null +++ b/docs/worklog/2026-09-19-assert-rederivation-did-not-come-up-short.json @@ -0,0 +1,52 @@ +{ + "date": "2026-09-19", + "taskId": "rustjava-assert-loadable-rederivation-did-not-come-up-short", + "summary": "The loadable re-derivation now proves itself instead of being trusted: registrations parsed are compared against an independent count of the proto calls in loader.rs, and registrations are compared against distinct resolved names. Both mismatches exit 2 (cannot measure). Starts green - all four counts are 268 today - and re-creating either of the two historical defects turns it red with a message naming what collapsed.", + "measurements": { + "proto_call_occurrences": 268, + "proto_comma_lines": 268, + "crate_classes_occurrences": 268, + "registrations_parsed": 268, + "distinct_names": 268, + "registrations_sharing_a_line": 0, + "proto_without_trailing_comma": 0, + "proto_in_comments": 0, + "duplicate_resolved_names": 0, + "files_changed": 1, + "lines_added": 38, + "lines_removed": 2, + "runtime_before_seconds": [ + 6.47, + 8.01, + 4.57 + ], + "runtime_after_seconds": [ + 6.35, + 5.68, + 7.53 + ] + }, + "verification": [ + "premise measured on origin/main @ ad9eb1a6: four independent counts of the registrations all give 268, and the four shapes that would make the assertion fire wrongly are all 0", + "axis 1, product script mutated back to the first draft's as_proto-only pattern: rc 2, '265 registrations parsed but 268 proto calls are in rustjava-runtime/src/loader.rs'", + "axis 2, product script mutated back to the first draft's bare-name keying: rc 2, '268 registrations resolved to only 263 names', naming java/util/Formatter <- java::util::Formatter::as_proto(), java::util::logging::Formatter::as_proto() among five", + "unmutated: rc 0, 43 named classes across 846 call sites, all 268 loadable", + "cost: before 6.47/8.01/4.57s vs after 6.35/5.68/7.53s, overlapping" + ], + "changes": [ + "scripts/check-named-exception-classes-are-loadable.py - PROTO_CALL witness plus two fail-closed assertions in loadable_classes()", + "docs/worklog/2026-09-19-assert-rederivation-did-not-come-up-short.{md,json}, REPORT.md, STATE.md" + ], + "issues": [ + "The proposal has no how field; this implementation follows its why and departs from its tradeoff.", + "Departure: the proposal expected coupling to the textual shape of loader.rs (one registration per line). Counting _proto() occurrences rather than lines avoids that, and measures equal today (268 = 268).", + "A genuine duplicate registration - two entries deliberately naming one class - would be a false red. There are none today and the message names them, but the risk is real.", + "If loader.rs ever calls as_proto() outside the registration array, the witness counts it and the check reddens on a correct tree. Measured today: all 268 calls are registrations.", + "It is a floor, not a proof: a registration mapped to a wrong but distinct name keeps both counts at 268 and passes.", + "Differs from the sibling round 2026-09-18-nonliteral-exception-call-sites#p0, which chose to print and never fail. That sized a known blind spot; this catches the check mis-reading its own input, which the file already treats as exit 2." + ], + "adoptedProposals": [ + "2026-09-19-loadable-set-source-of-truth#p0" + ], + "proposals": [] +} diff --git a/docs/worklog/2026-09-19-assert-rederivation-did-not-come-up-short.md b/docs/worklog/2026-09-19-assert-rederivation-did-not-come-up-short.md new file mode 100644 index 00000000..c9b12539 --- /dev/null +++ b/docs/worklog/2026-09-19-assert-rederivation-did-not-come-up-short.md @@ -0,0 +1,89 @@ +# 2026-09-19 — The check now proves its own reading of the loader, instead of trusting it + +Round: `rustjava-assert-loadable-rederivation-did-not-come-up-short` +Adopted proposal: `2026-09-19-loadable-set-source-of-truth#p0` + +The proposal has **no `how` field** — `title`, `plainSummary`, `userBenefit`, `why`, `tradeoff`, +`effort`, `target` only. What follows says where this implementation matches its `why` and where it +deliberately departs from its `tradeoff`. + +## First: is anything short today? + +No. Measured on `origin/main` @ `ad9eb1a6`, four independent counts of the same thing: + +| count | value | +|---|---| +| `_proto()` occurrences in `loader.rs` | **268** | +| `_proto(),` lines | 268 | +| `crate::classes::` occurrences | 268 | +| registrations `REGISTERED` parses | 268 | +| distinct names resolved | **268** | + +Also measured, because they are what would make the assertion fire *wrongly*: registrations sharing +a line **0**, `_proto()` without a trailing comma **0**, `_proto()` inside a comment **0**, duplicate +resolved names **0**. + +So the assertion starts green and guards a regression rather than fixing a present defect. The +proposal says as much; this round confirms it with numbers rather than assuming it. + +## What was added — two axes, both fail-closed + +1. **Parsed vs. witnessed.** `REGISTERED` is the pattern under suspicion, so counting its own matches + proves nothing. `PROTO_CALL` counts the same calls a second way, by the one token a registration + cannot be written without. Mismatch ⇒ `cannot measure` (exit 2). +2. **Registrations vs. distinct names.** Two registrations resolving to one name means the resolution + is wrong — it is exactly what bare-name keying did. Mismatch ⇒ exit 2, **naming the collapsed + pairs** so a genuine duplicate registration can be told from a mis-attribution at a glance. + +**Changed: 1 file, +38/−2.** + +## Axis — bidirectional, on the product script, by re-creating the two real defects + +| the script, mutated back to a defect it actually had | result | +|---|---| +| `((?:as\|list)_proto)` → `(as_proto)` (the first draft) | **rc 2** — `265 registrations parsed but 268 proto calls are in rustjava-runtime/src/loader.rs` | +| key by bare type name (the first draft) | **rc 2** — `268 registrations resolved to only 263 names`, then names them: `java/util/Formatter <- java::util::Formatter::as_proto(), java::util::logging::Formatter::as_proto()`, … | +| unmutated | **rc 0** — `✓ 43 named exception class(es) across 846 call site(s); all 268 loadable` | + +The second message is the point of the round: gate 2 found that colliding pair by reading the source +alongside the script. The script now says it. + +## The three questions the brief asked + +**⒜ What is it compared against?** The source itself — a second count of the same file. Not a stored +baseline, not the previous run. + +**⒝ First run, and legitimate decreases?** They do not arise, and that is *why* this shape was +chosen. A remembered number would have to answer both; a self-contained invariant answers neither +because it never remembers anything. Removing a class legitimately drops all counts together and +stays green. + +**⒞ Die or speak?** **Die — exit 2, `cannot measure`.** The sibling round +(`2026-09-18-nonliteral-exception-call-sites#p0`) chose "count and print, never fail" for the +non-literal blind spot, and this round deliberately differs: that is a *known limitation* being +sized, this is the check *mis-reading its own input*. The file already has a category for the +latter — it dies on a registered entry whose name cannot be resolved — and this is the same failure +one step earlier. A check whose loadable set is short reports real classes as unloadable and missing +ones as present; printing that and exiting 0 would be the silent pass the file's docstring is about. + +## What this costs + +- **Two more numbers that have to stay true.** If `loader.rs` ever calls `as_proto()` outside the + registration array, `PROTO_CALL` counts it and the check goes red on a correct tree. Measured + today: every one of the 268 calls is a registration (`crate::classes::` count matches exactly). +- **A false red is possible for a genuine duplicate registration** — two entries deliberately naming + one class. There are none today, and the message names them, but it would be a red on a tree that + is arguably fine. +- **It is a floor, not a proof** — the proposal's own words. A registration mapped to a *wrong but + distinct* name keeps both counts at 268 and passes. This catches undercounts, which is what both + measured false greens were. +- Runtime: **no significant change** — before 6.47 / 8.01 / 4.57 s, after 6.35 / 5.68 / 7.53 s + (overlapping; the machine is loaded and the spread is wider than the effect). + +## Departure from the proposal's `tradeoff` + +It predicted the check would be *"coupled to the textual shape of `loader.rs` (one registration per +line), which is true today and is not guaranteed."* That coupling was avoidable and was avoided: +counting `_proto()` **occurrences** rather than lines makes the witness independent of line layout +and of trailing-comma style. Measured equal to the line count today (268 = 268), so nothing is lost +by the more robust form. diff --git a/scripts/check-named-exception-classes-are-loadable.py b/scripts/check-named-exception-classes-are-loadable.py index 3f422b1a..40f3f187 100755 --- a/scripts/check-named-exception-classes-are-loadable.py +++ b/scripts/check-named-exception-classes-are-loadable.py @@ -107,6 +107,13 @@ IS_DEFINITION = re.compile(r"\bfn\s+$") +# The independent witness for "did the parse come up short". `REGISTERED` is the pattern under +# suspicion, so counting its own matches proves nothing; this counts the same calls a second way, +# by the one token a registration cannot be written without. Occurrences rather than lines, and no +# trailing comma, so it does not care how the list is formatted -- measured on this file: `_proto()` +# 268, `_proto(),` lines 268, `crate::classes::` 268, registrations parsed 268, all agreeing. +PROTO_CALL = re.compile(r"_proto\(\)") + def die(message): print(f"cannot measure: {message}", file=sys.stderr) raise SystemExit(2) @@ -229,20 +236,49 @@ def loadable_classes(): if field: name_of[(module, type_name, function.group(1))] = field.group(1) - registered = REGISTERED.findall(read(LOADER)) + loader_text = read(LOADER) + registered = REGISTERED.findall(loader_text) if not registered: die(f"no proto registrations found in {LOADER.relative_to(ROOT)}") - names, unresolved = set(), [] + # Did this parse come up short? The whole check rests on `registered` being every registration, + # and the failure mode is silent: a pattern that matches fewer entries yields a smaller loadable + # set, and a smaller loadable set makes missing classes look present. Measured on the first draft + # of this file: it matched only `as_proto`, parsed 265 of 268 and resolved 263 names, and said + # nothing. Counting the calls a second, independent way turns that into an exit 2. + witness = len(PROTO_CALL.findall(loader_text)) + if len(registered) != witness: + die( + f"{len(registered)} registrations parsed but {witness} proto calls are in " + f"{LOADER.relative_to(ROOT)}. The pattern that reads them is missing some, so the loadable " + "set is short and every class it lost would read as 'not loadable'. Fix REGISTERED rather " + "than trusting this run." + ) + + names, unresolved, name_of_entry = set(), [], {} for path, function in registered: parts = path.split("::") key = ("::".join(parts[:-1]), parts[-1], function) if key in name_of: names.add(name_of[key]) + name_of_entry.setdefault(name_of[key], []).append(f"{path}::{function}()") else: unresolved.append(f"{path}::{function}()") if unresolved: die("registered entries with no resolvable name: " + ", ".join(sorted(set(unresolved)))) + + # Two registrations that resolve to one name mean the resolution is wrong, not that the runtime + # has a duplicate: it is what the first draft did when it keyed types by bare name and let one of + # a colliding pair answer for its twin (265 parsed, 263 names). Named rather than counted, so the + # reader can tell a genuine duplicate registration from a mis-attribution at a glance. + collapsed = {name: entries for name, entries in name_of_entry.items() if len(entries) > 1} + if collapsed: + detail = "; ".join(f"{name} <- {', '.join(sorted(entries))}" for name, entries in sorted(collapsed.items())) + die( + f"{len(registered)} registrations resolved to only {len(names)} names. Two registrations " + f"naming one class means this file read them wrong, and a short loadable set reads as " + f"'not loadable': {detail}" + ) return names