docs: add amlich improvement research, brainstorm decision, and completed plan

This commit is contained in:
2026-08-18 23:00:39 +07:00
parent dd1d1a2727
commit 9fa2954123
6 changed files with 480 additions and 0 deletions
@@ -0,0 +1,75 @@
---
phase: 1
title: Lunar core helpers
status: completed
effort: S
priority: P2
dependencies: []
---
# Phase 1: Lunar core helpers
## Overview
Add the two pure helpers the handlers need: the disputed-boundary set + membership check, and
leap-month existence probing. No behavior change to existing conversion functions.
## Requirements
- Functional: `nearDisputedBoundary(jdn int) bool` reports whether the lunar month containing
solar day `jdn` starts or ends on one of the 7 disputed month boundaries.
- Leap-variant existence needs NO new helper: handlers probe `lunarToSolar(day, month, year, true)`
and treat `err == nil` as "leap variant exists" (DRY — reuses existing validation; also naturally
suppresses the hint when e.g. day 30 doesn't exist in the 29-day leap month).
- Non-functional: helpers stay in `lunar.go`, unexported, zero allocations on the hot path
(package-level set built once).
## Architecture
- `disputedMonthStarts` — package-level `map[int]bool` built in a `var` initializer from
`jdFromDate` on the 7 dates recorded in `docs/amlich-known-issues.md`:
09/12/2072, 15/11/2077, 07/05/2130, 26/05/2150, 17/05/2159, 22/01/2175, 26/01/2199.
- `nearDisputedBoundary(jdn int) bool` — mirror `solarToLunar`'s month-start search:
```go
k := floorInt((float64(jdn) - jdNewMoonEpoch) / newMoonCycle)
monthStart := getNewMoonDay(k + 1)
for monthStart > jdn {
k--
monthStart = getNewMoonDay(k + 1)
}
return disputedMonthStarts[monthStart] || disputedMonthStarts[getNewMoonDay(k+2)]
```
Rationale for checking next start too: if the *end* boundary of the containing month is disputed,
the month's length (and day numbers near its end) is what may shift.
## Related Code Files
- Modify: `internal/modules/amlich/lunar.go`
- Modify: `internal/modules/amlich/lunar_test.go`
## Implementation Steps
1. Add `disputedMonthStarts` set with a comment explaining provenance (new moon within ±2 min of
UTC+7 midnight; see docs/amlich-known-issues.md) — no plan/audit labels in comments.
2. Add `nearDisputedBoundary`.
3. Test: each of the 7 JDs is an actual month start per the engine — for each date, assert
`solarToLunar(d,m,y)` returns lunar day 1. If any assertion fails ±1 day, the doc date and the
engine's boundary disagree: adjust the set entry to the engine's month start and flag the doc
discrepancy in the phase report.
4. Test `nearDisputedBoundary`: true for a mid-month day of the month starting 09/12/2072, true for
a day in the month *before* it (whose end boundary is disputed), false for an ordinary 2072 date
far from the boundary and for a 2024 date.
## Success Criteria
- [ ] 7 set entries verified as engine month starts by test
- [ ] `nearDisputedBoundary` true/false cases covered as above
- [ ] Existing lunar tests untouched and green
## Risk Assessment
- Doc dates might be the full-Meeus engine's boundaries rather than the current engine's (off by
one day). Mitigation: step 3's pin test resolves it mechanically; the ±1-day neighborhood is the
same disputed lunation either way.
@@ -0,0 +1,66 @@
---
phase: 2
title: Handler replies
status: completed
effort: S
priority: P2
dependencies:
- 1
---
# Phase 2: Handler replies
## Overview
Wire the leap-month hint into `/duonglich` and the razor-edge caveat into both commands. Reply
sentences gain optional trailing lines only; existing first-line format is unchanged.
## Requirements
- Functional (`/duonglich` hint): after a successful conversion with `leap == false`, if
`lunarToSolar(day, month, year, true)` returns nil error, append:
`Lưu ý: năm âm lịch <year> có tháng <month> nhuận — thêm "nhuan" nếu ý bạn là tháng nhuận.`
- Functional (caveat, both commands): if `nearDisputedBoundary(jd)` — where `jd` is the *solar* JD
of the queried/resulting date — append:
`Lưu ý: ngày này gần ranh giới tháng âm lịch chưa chắc chắn; kết quả có thể lệch 1 ngày so với lịch chính thức sau này.`
- Hint NOT shown when `nhuan` was explicit (user already disambiguated) or when the exact leap date
doesn't exist. Both lines may co-occur (hint first, then caveat), each on its own line after the
main sentence, separated by `\n`.
- Non-functional: no change to usage strings, error paths, or year-range checks.
## Architecture
- `/amlich`: `jd := jdFromDate(day, month, year)` (already-parsed solar input) → caveat check.
- `/duonglich`: caveat check on `jdFromDate(solarDay, solarMonth, solarYear)` from the conversion
result; hint check via the leap-variant probe before assembling the reply.
- Message constants live next to the usage constants in `handlers.go`.
## Related Code Files
- Modify: `internal/modules/amlich/handlers.go`
- Modify: `internal/modules/amlich/handlers_test.go`
## Implementation Steps
1. Add the two message constants (hint as format string taking year + month).
2. `/duonglich`: after successful `lunarToSolar`, build reply, conditionally append hint (probe
with `leap=true` only when input `leap == false`), then conditionally append caveat.
3. `/amlich`: conditionally append caveat after the main sentence.
4. Tests (extend existing fake-reply pattern in `handlers_test.go`):
- `/duonglich 5/5/2028` → hint present (2028 has leap 5); `/duonglich 5/5/2028 nhuan` → absent;
`/duonglich 5/5/2027` → absent (no leap 5 in 2027).
- `/amlich` on a date inside the disputed month at 09/12/2072 → caveat present; ordinary date →
absent. `/duonglich` case whose result lands in that month → caveat present.
- Assert exact full reply strings (repo test style asserts exact text — keep it strict).
## Success Criteria
- [ ] Hint behavior matches the three `/duonglich` cases above
- [ ] Caveat fires for disputed-month dates in both commands, silent elsewhere
- [ ] All pre-existing handler tests pass without assertion loosening
## Risk Assessment
- Double-probe cost: one extra `lunarToSolar` call per leap-year query — microseconds, irrelevant.
- Wording is user-visible contract; if the user wants different Vietnamese phrasing, only the
constants change. Flag wording in the PR description for review.
@@ -0,0 +1,75 @@
---
phase: 3
title: Golden-table testdata and docs
status: completed
effort: S
priority: P3
dependencies: []
---
# Phase 3: Golden-table testdata and docs
## Overview
Freeze the verified 18002199 month structure as committed testdata, and close the resolved open
questions in `docs/amlich-known-issues.md`. Independent of phases 12.
## Requirements
- Functional: a test recomputes every lunar year's structure from the engine and compares
byte-exact against `testdata/lunar-years-1800-2199.txt`; `go test -run TestGoldenTable -update`
regenerates the file (standard `-update` flag idiom).
- File format, one line per lunar year:
`YYYY L n1 n2 ... nN``L` = leap month number (0 = none), `n*` = month lengths (29/30) in
chronological order, tháng 1 first, leap month inserted in sequence after month `L`
(12 entries normal year, 13 leap year).
- Non-functional: file lives in `internal/modules/amlich/testdata/`; generation logic lives in the
test file only (no production code).
## Architecture
- Build each year from `lunarToSolar` month starts: for lunar year Y, JDs of tháng 1..12 (+ leap
where `lunarToSolar(1, m, Y, true)` succeeds), sorted chronologically; lengths = successive
start-JD differences, last month's length from tháng 1 of Y+1. Simpler than sweeping days and
exercises the lunar→solar direction the round-trip test already covers from the other side.
- Rationale over round-trip: `TestSolarLunarRoundTrip` proves self-consistency only; a future
engine change that shifts a boundary consistently in both conversions passes it. The golden file
pins the actual verified placement.
## Related Code Files
- Create: `internal/modules/amlich/testdata/lunar-years-1800-2199.txt`
- Modify: `internal/modules/amlich/lunar_test.go` (or new `golden_test.go` if it crowds the file)
- Modify: `docs/amlich-known-issues.md`
## Implementation Steps
1. Write `TestGoldenTable` with `var update = flag.Bool("update", false, ...)`; generator builds the
full table string; when `-update`, write file and skip compare; else compare byte-exact with a
diff-friendly failure message (first differing line).
2. Generate the file once; eyeball spot checks: 2025 leap 6, 2028 leap 5, 2033 leap 11, 1944
leap 4, 1967 no leap month (leap numbers already pinned by `TestLeapMonthTable` — must agree;
the doc's "30/5 Đinh Mùi" for 1967 is day 30 of the regular month 5, not a leap month).
3. Commit the generated file. Edge-case years must round-trip with the existing suite untouched.
4. Update `docs/amlich-known-issues.md`:
- Open question 1 → resolved: caveat line added, months touching the boundary only.
- Open question 2 → closed (don't build): add the South-Vietnam wrinkle (UTC+7 until 1959,
UTC+8 196067 per Hồ Ngọc Đức's historic-calendar page) — a single UTC+8 mode would be wrong
for the South 195559, strengthening the existing conclusion.
- "`/duonglich` defaults inside leap months" item → note the hint now self-disambiguates.
- Keep questions 3 (ΔT) and 4 (future official tables) open; add one line noting the 2026 CGPM
leap-second-abolition vote as a further far-future timescale uncertainty in the same bucket.
## Success Criteria
- [ ] Golden file committed; regeneration from HEAD is byte-identical
- [ ] Golden leap months agree with `TestLeapMonthTable` pins
- [ ] Docs updated; no stale claims left in the two resolved items
- [ ] `docs/amlich-known-issues.md` stays under docs.maxLoc (800)
## Risk Assessment
- Ordering bug when inserting the leap month (nhuận m sorts after regular m) — chronological
sort by start JD avoids hand-rolled index math.
- `flag.Bool` at package scope collides if a flag named `update` ever exists elsewhere in the
package's tests — it doesn't today; keep the name.
@@ -0,0 +1,64 @@
---
title: >-
Amlich converter improvements: leap-month hint, razor-edge caveat,
golden-table tests
description: >-
Three merged non-breaking improvements to internal/modules/amlich from
brainstorm decision
status: completed
priority: P2
branch: main
tags:
- amlich
- telegram-bot
blockedBy: []
blocks: []
created: '2026-08-18T15:04:11.318Z'
createdBy: 'ck:plan'
source: skill
---
# Amlich converter improvements: leap-month hint, razor-edge caveat, golden-table tests
## Overview
Implement the three improvements selected in
`plans/reports/brainstorm-decision-260818-2158-amlich-improvements-selection-report.md`
(research: `plans/reports/research-brainstorm-260818-2147-amlich-converter-improvements-report.md`):
1. `/duonglich` leap-month ambiguity hint — when the entered month is also that year's leap month
and no `nhuan` flag given, append a one-line hint.
2. Razor-edge caveat — both commands warn when the result's lunar month touches one of the 7
disputed month boundaries from 2072 on (documented in `docs/amlich-known-issues.md`).
3. Golden-table regression testdata — freeze the verified 18002199 month-structure output.
Explicitly out of scope (rejected with verification in the research report): ΔT model update,
pre-1968 historic mode, table-driven engine rewrite, range extension, extra calendar features.
## Phases
| Phase | Name | Status |
|-------|------|--------|
| 1 | [Lunar core helpers](./phase-01-lunar-core-helpers.md) | Completed |
| 2 | [Handler replies](./phase-02-handler-replies.md) | Completed |
| 3 | [Golden-table testdata and docs](./phase-03-golden-table-testdata-and-docs.md) | Completed |
## Dependencies
None cross-plan. Phase 2 depends on phase 1; phase 3 is independent.
## Acceptance Criteria (whole plan)
- All existing tests pass unchanged — especially `knownDates` pins (20/6/1944, 7/7/1967),
`TestSolarLunarRoundTrip`, `TestLeapMonthTable`.
- `/duonglich 5/5/2028` (leap-5 year) shows the hint; `/duonglich 5/5/2028 nhuan` and non-leap
years do not.
- Conversions inside a lunar month adjacent to the 09/12/2072 boundary show the caveat (both
commands); ordinary dates do not.
- Golden file regenerated from HEAD is byte-identical to the committed one.
- No public-contract changes; reply format only gains optional trailing lines.
- `go vet ./...` and `staticcheck` clean (repo standard).
## Validation
`go test ./internal/modules/amlich/` after each phase; full `go test ./...` + lint at the end.
@@ -0,0 +1,72 @@
# Brainstorm Decision: amlich Converter Improvements — Selected Scope
Date: 2026-08-18 21:58 (+07)
Basis: `plans/reports/research-brainstorm-260818-2147-amlich-converter-improvements-report.md`
Mode: user delegated choice ("choose the best, merge if mergable").
## Decision
All three recommended items merged into one change set — they are complementary (input UX,
output honesty, test hardening), not alternatives. Rejections from the research report stand.
### In scope
1. **Leap-month hint in `/duonglich`** (highest user value, do first)
- Trigger: resolved month == that lunar year's leap month AND `nhuan` flag absent.
- Output: append "Năm nay có tháng X nhuận — thêm 'nhuan' nếu ý bạn là tháng nhuận."
- Impl: `leapMonthOf(year int) (month int, ok bool)` helper in `lunar.go` reusing
`getLunarMonth11` + `getLeapMonthOffset`; hint assembled in `handlers.go`.
- NOT triggered when `nhuan` explicit (resolves research report open question 2: no).
2. **Razor-edge caveat in both commands**
- Data: 7 disputed month-start JDs (new moons of 09/12/2072, 15/11/2077, 07/05/2130,
26/05/2150, 17/05/2159, 22/01/2175, 26/01/2199) as a package-level set in `lunar.go`,
JD values derived at implementation time and pinned by test.
- Trigger: containing month start OR next month start is in the set → append one-line
caveat: result near disputed lunar-month boundary, may differ ±1 day from future
official tables.
- Scope: months touching the boundary only, not the whole lunar year (resolves research
report open question 1: months-only; year-wide is alarmist).
- Impl: `nearDisputedBoundary(jdn int) bool` helper; handlers call it with the solar JD.
3. **Golden-table regression testdata**
- `internal/modules/amlich/testdata/lunar-years-1800-2199.txt`: one line per year —
year, leap-month index (0 = none), month lengths in order (12 or 13 entries).
- Test recomputes from engine, compares byte-exact; `-update` flag idiom regenerates.
- Rationale: round-trip test proves self-consistency only; golden table freezes the
verified boundary placement against silent drift.
### Out of scope (rejected, verified in research report)
- ΔT model update — breaks bit-compatibility with ecosystem; verified by prior 400-year diff.
- Pre-1968 historic mode — ground truth fragments (North UTC+8 194567; South UTC+7→UTC+8 1960).
- Table-driven engine rewrite — no authoritative source beyond current engine; test-data-only instead.
- Range extension beyond 18002199; can-chi day names, tiết khí, holiday lookup.
## Touchpoints
- `internal/modules/amlich/lunar.go``leapMonthOf`, `nearDisputedBoundary`, disputed-JD set.
- `internal/modules/amlich/handlers.go` — hint + caveat lines in both command replies.
- `internal/modules/amlich/lunar_test.go`, `handlers_test.go` — new cases; existing pins untouched.
- `internal/modules/amlich/testdata/` — new golden file.
- `docs/amlich-known-issues.md` — close open questions 12 with these resolutions.
## Acceptance Criteria
- All existing tests pass unchanged, incl. `knownDates` (20/6/1944, 7/7/1967) and full round-trip.
- `/duonglich 5/5/2028` (leap-5 year) shows hint; `/duonglich 5/5/2028 nhuan` and non-leap years don't.
- `/amlich` for a date inside a month adjacent to 09/12/2072 boundary shows caveat; ordinary dates don't;
`/duonglich` symmetric.
- Golden file regenerated from HEAD is byte-identical to committed version.
- ~100 LOC total incl. tests; no public-contract changes.
## Risks
- Caveat JD derivation error → wrong months flagged. Mitigation: pin the 7 JDs in a test that
recomputes them from `getNewMoonDay`.
- Reply strings are user-visible contract for handler tests — update expected strings, don't loosen asserts.
## Unresolved Questions
- None blocking. Post-leap-second timescale question stays parked in `docs/amlich-known-issues.md`
open question 4.
@@ -0,0 +1,128 @@
# Research + Brainstorm Report: Improving the amlich/duonglich Converter
Date: 2026-08-18 21:47 (+07)
Scope: `internal/modules/amlich` — what improvements remain, ranked; what to explicitly reject.
Inputs: repo code + `docs/amlich-known-issues.md` (prior 400-year Meeus-vs-HND diff), 5 web lookups (2 WebSearch, 3 WebFetch).
## Executive Summary
Algorithm question is settled and should stay settled: the truncated Hồ Ngọc Đức port is bit-compatible
with the de-facto Vietnamese ecosystem and empirically beat a full Meeus ch.49 engine on both
historically verifiable razor-edge dates. No engine change is justified by any new evidence found.
Remaining improvement space is small and mostly UX/honesty, not math:
(1) caveat line for the 7 razor-edge lunations 2072+, (2) leap-month ambiguity hint in `/duonglich`,
(3) optional golden-table regression hardening. Everything else researched — ΔT model update,
pre-1968 historic mode, table-driven rewrite, range extension — should be rejected; reasons below.
## New Research Findings (2026)
- **ΔT trend confirms doc's suspicion, changes nothing.** Earth set rotation-speed records in 20242025;
IERS added no leap second in 2024; ~30% chance of a first-ever *negative* leap second before 2035.
ΔT is flat-to-declining vs the polynomial's predicted growth — so the code's ΔT branch overestimates
future ΔT. But this only matters inside the already-documented razor-edge windows. See "Reject: ΔT".
- **Leap-second abolition adds a *new* far-future uncertainty.** CGPM votes Oct 2026 to replace the leap
second (possibly retiring it as early as 2027). If UTC stops tracking UT1, civil UTC+7 slowly drifts
from the astronomical time the algorithm models — same bucket as ΔT: only razor-edge relevant,
unresolvable until Vietnam's authorities say which timescale the calendar follows.
- **No official Vietnamese tables exist past ~2100.** Nothing from a state calendar bureau covering
2072+ disputes surfaced. Hồ Ngọc Đức remains the de-facto ground truth; his site claims historic
reliability "since 1301" and notes official/astronomical calendars coincide since 1976.
- **Pre-1968 is messier than "UTC+8".** Per HND's historic-calendar page: North used UTC+8 194567;
South used UTC+7 until 1959 then UTC+8 196067; pre-1945 rests on dynastic tables (Bách trúng kinh
16241799, Khâm định vạn niên thư 15541903). A single "UTC+8 historic mode" (open question 2 in
known-issues) would be *wrong for South Vietnam 195559* — the idea is even weaker than documented.
- **Go ecosystem check.** Other Go ports (hungtrd/amlich, buichuongvnua/amlich, go-dyn/vcalendar) are
straight HND ports without the day-0 overshoot fix or input validation this repo already has.
Nothing to borrow; this implementation is ahead of them.
## Brainstorm: Evaluated Approaches
### Recommend — small, honest, non-breaking
**1. Razor-edge caveat in bot replies** (resolves known-issues open question 1)
- What: hardcode the 7 disputed month-boundary JDs (09/12/2072, 15/11/2077, 07/05/2130, 26/05/2150,
17/05/2159, 22/01/2175, 26/01/2199). When a conversion's month start or next-month start is one of
them, append one line: result near a disputed lunar-month boundary, may differ ±1 day from future
official tables.
- Affects ~413 of 146,097 days; zero risk to correct output; converts a silent known-wrongness into
stated uncertainty. Cost: a small table + one condition + tests.
- Trade-off: nobody realistically queries 2130 from a Telegram bot — pure-YAGNI reading says skip.
But the cost is ~30 LOC and it closes a documented open question permanently.
**2. Leap-month ambiguity hint in `/duonglich`** (fixes the "correct output reported as bug" item)
- What: when the resolved (possibly defaulted) month equals that lunar year's leap month and no
`nhuan` flag was given, append a hint: "Năm nay có tháng X nhuận — thêm 'nhuan' nếu ý bạn là
tháng nhuận." Detection is one `getLeapMonthOffset` call on the already-computed a11.
- Alternatives considered: (a) reply with *both* conversions — noisier, two answers where user wants
one; (b) leave as-is — keeps a documented user-confusion source. Hint is the KISS winner:
single authoritative answer + self-service disambiguation.
**3. Golden-table regression corpus** (optional hardening)
- What: generate once, from the current verified engine, a compact per-year record (leap-month index +
12/13 month lengths) for all 400 years; commit as testdata; test decodes and compares.
- Why round-trip isn't enough: `TestSolarLunarRoundTrip` proves *self-consistency*; a future change
could shift a month boundary consistently in both directions and pass. `knownDates` +
`TestLeapMonthTable` pin samples only. A golden table freezes the full verified behavior and makes
any future engine experiment a reviewable one-file diff.
- Cost: ~1 generator run + ~4 KB testdata + one test. Verdict: worth it, do alongside #1.
### Reject — with reasons pinned
**ΔT model update.** New IERS data makes the polynomial *more* wrong, yet updating it is still wrong to
do: the module's value is bit-compatibility with the reference algorithm every Vietnamese app runs.
A "better" ΔT flips razor-edge dates away from ecosystem consensus → user-visible mismatches
reported as bugs, with no authority to say we're right. Revisit only if official 2072+ tables appear
(known-issues open question 4). Approach #1 (caveat) is the correct treatment of this uncertainty.
**Pre-1968 historic mode.** Ground truth fragments by government (North/South differ 195567, dynastic
before 1945); a correct implementation is a research project, not a module feature; the proleptic
astronomical calendar is what every reference source shows for those years anyway. Current behavior
already matches the published record on both verifiable disputes. Keep documented, don't build.
This *strengthens* the known-issues answer to open question 2: even a user-reported mismatch should
trigger a doc note, not a UTC+8 mode.
**Table-driven rewrite.** A table must be generated from something. From this engine → just a cache of
identical output (Go float64 JD math has ~4.5e-10-day ulp vs 0.0014-day decision margins; no
platform-flip risk to cache away). From official tables → they don't exist past ~2100. Table-driven
is how you'd start from scratch; with a verified engine it adds a second representation to keep in
sync (DRY violation) for zero accuracy. The useful 20% of this idea is #3 (table as *test* data).
**Range extension beyond 18002199.** Bound exists because published references stop there; claims
outside are unverifiable. Nothing found changes that.
**Feature creep** (ngày can-chi, tiết khí, giờ hoàng đạo, holiday lookup): out of scope until a user
asks. Noted so it isn't re-brainstormed.
## Success Criteria (if #1#3 are implemented)
- All existing tests pass unchanged, incl. `knownDates` pins (20/6/1944, 7/7/1967).
- Caveat appears for a 2072+ razor-edge date, absent for ordinary dates (both directions of conversion).
- `/duonglich 5/5/2028` (leap-5 year) shows hint; `/duonglich 5/5/2028 nhuan` and non-leap years don't.
- Golden table regenerated from HEAD is byte-identical to committed testdata.
## Next Steps
1. Decide which of #1/#2/#3 to implement (recommendation: all three; #2 first — most user-visible).
2. `/ck:plan` with this report as context if proceeding; scope is small enough for a single phase.
3. Update `docs/amlich-known-issues.md` open questions 13 with the resolutions above once implemented.
## Sources
- [Hồ Ngọc Đức — Vietnamese lunar calendar](https://www.xemamlich.uhm.vn/vncal_en.html)
- [Hồ Ngọc Đức — Historic Vietnamese lunar calendar](https://www.xemamlich.uhm.vn/histcal.html)
- [Vietnamese calendar — Wikipedia](https://en.wikipedia.org/wiki/Vietnamese_calendar)
- [IERS: no leap second in 2024 — DCD](https://www.datacenterdynamics.com/en/news/no-leap-seconds-added-to-universal-time-in-2024-iers-says/)
- [Earth rotation records spur Oct 2026 CGPM vote — TechTimes](https://www.techtimes.com/articles/320185/20260711/earth-rotation-records-spur-october-vote-avert-negative-leap-second.htm)
- [Negative leap second outlook — timeanddate](https://www.timeanddate.com/time/negative-leap-second-maybe.html)
- [Earth rotation acceleration analysis — Astronomy Reports 2024](https://arxiv.org/html/2404.06343v3)
- Go ports surveyed: [hungtrd/amlich](https://github.com/hungtrd/amlich), [buichuongvnua/amlich](https://pkg.go.dev/github.com/buichuongvnua/amlich), [go-dyn/vcalendar](https://pkg.go.dev/github.com/go-dyn/vcalendar)
## Unresolved Questions
1. Caveat wording/threshold for #1: flag only the two months touching a disputed boundary (proposed),
or the whole lunar year? Proposed: months only — year-wide is alarmist.
2. Should the leap-month hint (#2) also fire when month+`nhuan` *was* given but the defaulted year
was filled in (user may have meant a different year)? Proposed: no — over-engineering.
3. If leap seconds are abolished (Oct 2026 vote), does Vietnam's calendar follow civil UTC+7 or
UT1+7? Unanswerable today; park with known-issues open question 4.