Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .claude/hooks/gates_after_commit.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

NOT AUTHORITATIVE. CLAUDE.md is explicit that CI decides, and a whole branch once
merged with CI red on every commit. `mypy` and the C++ suite are omitted here
because they need a build; green from this hook means "these four gates passed on
because they need a build; green from this hook means "the GATES below passed on
these files", which is strictly less than `gh pr checks`.
"""

Expand All @@ -37,13 +37,14 @@
#: regenerates uv.lock, and uv.lock is gitignored on this branch precisely
#: because 1016 lines of it rode into a commit that way. A hook scheduling that
#: command on every commit would recreate the artifact forever. CI runs plain
#: `ruff check .` (.github/workflows/main.yaml).
#: `ruff check .` and `ruff format --check .` (.github/workflows/main.yaml).
GATES = (
["python3", "tools/check_prohibited_deps.py"],
["python3", "tools/check_legacy_imports.py"],
["python3", "tools/check_detria_boundary.py"],
["python3", "tools/check_citations.py"],
[str(ROOT / ".venv" / "bin" / "ruff"), "check", "."],
[str(ROOT / ".venv" / "bin" / "ruff"), "format", "--check", "."],
)


Expand Down
5 changes: 5 additions & 0 deletions .git-blame-ignore-revs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Mechanical commits `git blame` should look past.
# Use: git config blame.ignoreRevsFile .git-blame-ignore-revs

# ruff format of the tree, when `ruff format --check` became a CI gate
41db2ab24ab76c8e2f7abb5f98ddae174bf4765b
7 changes: 7 additions & 0 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -214,3 +214,10 @@ jobs:
- name: ruff
if: matrix.python-version == '3.12'
run: ruff check .

# Layout is enforced, not advised. `# fmt: skip` and `# fmt: off` are
# allowed with a reason (CLAUDE.md section 2); docs/ and .claude/ are
# excluded in pyproject.toml.
- name: ruff format
if: matrix.python-version == '3.12'
run: ruff format --check .
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@ This project is governed by specialized sub-agents. Always defer tasks to the co
* **Strict Size Limit:** Under **700 lines of production code per pull request**,
where a line counts unless it is a comment, a docstring, or the body of a raw
literal; tests excluded. The exclusions exist so the ceiling does not penalise
the comment density this project asks for. This is the only statement of the
rule; everywhere else points here.
the comment density this project asks for. Lines count as written: packing
code by hand under `# fmt: skip` / `# fmt: off` is allowed, provided the
packed lines stay readable and the review says why each new region is
packed (Ola, 2026-09-27, on `tools/bench.py`). This is the only statement of
the rule; everywhere else points here.
* **Prohibited Dependencies:** Never introduce `CGAL`, `GDAL`, `OGR`, `Fiona`,
`Rasterio` (it wraps GDAL), or external `date` libraries.
Enforced by `tools/check_prohibited_deps.py` over imports, includes, declared
Expand Down Expand Up @@ -60,6 +63,7 @@ ctest --test-dir build # all registered suites; see tes
```bash
mypy # strict, over src_python/tin_engine
ruff check . # legacy/ is excluded
ruff format --check . # docs/ and .claude/ are excluded
```

### Governance gates
Expand Down
38 changes: 36 additions & 2 deletions docs/benchmarks/bench-py.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,12 +172,18 @@ baseline search 60, evidence writing 70, Typer app 50: about 500 lines, under
the 700 ceiling. Plus `tools/bench.py` added to mypy's `files` in
`pyproject.toml`, so the strict gate covers it.

Measured at green: about 690 lines counted as section 2 counts them (blank
Measured at green (`90f45ae`): about 690 lines counted as section 2 counts them (blank
lines in, comments and docstrings out; 688 to 691 depending on whether the two
`# fmt: off/on` lines count), 582 without blank lines, so the estimate was low
by about 80 lines. The margin rests on 16 regions packed by hand under
`# fmt: skip` / `# fmt: off`; formatted normally it is about 780 counted lines
(827 raw). The next change to `tools/bench.py` has about 10 lines of room.
(827 raw). That total was the first PR's count, because that PR added the
whole file; CLAUDE.md section 2 says what a later PR counts. The `# fmt:
off/on` pair was removed in `91d5b76`: its `on` was indented, so ruff never
switched formatting back on, and the format gate skipped everything from `run`
to the end of the file. The signature it guarded was already in ruff's layout.
The packed regions left are `# fmt: skip` lines (`grep -c "fmt: skip"
tools/bench.py`).

## Ruled by Ola (2026-09-27)

Expand Down Expand Up @@ -294,6 +300,34 @@ whose generated part ends at the line `MARKER`; whatever follows that line in
an existing README is kept on a rerun. The README names `2.2x` beside the
ceiling, every verdict line, and `--accept-quality` when it was used.

**The threshold boundary** (follow-up red, 2026-09-27). "Strictly more than
the threshold" is decided without a rounded ratio: a median exactly
`threshold_pct` above the baseline's is `ACCEPTED`. With medians 20.0 and 21.0
the ratio `21.0 / 20.0 - 1` rounds to 0.050000000000000044, so a
`pct > threshold` test called exactly 5 % a regression; comparing exact
products (`new * 100 > base * (100 + threshold)`) does not. 5.5 % at the
default and 8 % at 7.5 are regressions with their size.

**Bad input exits 3, not a traceback** (follow-up red, 2026-09-27). Exit 3
through `typer.Exit`, one line naming the offending file, no evidence written:

- `run` with a `--dem` or `--domain` path that does not exist is refused
**before the build and before any child**, so a typo is not found out after
the measurement it would spoil.
- A `run.json` that does not load as a `RunRecord` (truncated, empty, or valid
JSON from another schema) and that the caller **named** is refused:
`compare NEW_DIR` (also when `NEW_DIR/run.json` is missing), `compare
--baseline DIR`, and `run --baseline DIR`, the last before the build and any
child.
- *Settled here:* a malformed `run.json` met during the **baseline search**
(`find_baseline`, from `run` or `compare` without `--baseline`) is
**skipped with a `UserWarning` naming its path**, and the search goes on.
Not an error, because `run` searches after measuring: an error there would
throw away the measurement over a file unrelated to it, and a `RunRecord`
that gains a required field would make every older record invalid and every
run fail. Not silent, because a skipped record can change which baseline is
chosen, and the warning is how the reader of the verdict learns that.

## Settled at green (@perf, 2026-09-27)

**Where the Release `.so` lands and how `pkg/` is assembled** (the red suite
Expand Down
2 changes: 1 addition & 1 deletion docs/increments/05b-noder-driver.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ depends on 6b-ii merging, 5b does not depend on it at all. Three things it buys,
in descending order of how load-bearing they are:

1. **It has already falsified a piece of this design, before any code was
written.** `src_python/tin_engine/viz/scene.py:200`'s `build_scene(pslg,
written.** `src_python/tin_engine/viz/scene.py:209`'s `build_scene(pslg,
mesh, ...)` joins the PSLG's chain edges to the mesh's masked edges **by
vertex-index pair** (`_chain_edges` builds `dict[Pair, ...]` from
`indices_of(c)`; `_mesh_edges` builds `set[Pair]` from triangle corners).
Expand Down
15 changes: 8 additions & 7 deletions docs/increments/05c-noder-wiring.md
Original file line number Diff line number Diff line change
Expand Up @@ -632,22 +632,23 @@ product. Renaming it destroys that.
**Its module docstring becomes wrong and is fixed in this PR**, per
`docs/increments/README.md`'s rule that a documentation defect found during an
increment is fixed in that increment's PR or not recorded.
`viz/fixtures.py:21-26` says "**Three** of the eight are deliberate failures...
Line numbers in this passage are at `605a60c`, where this record was written.
`viz/fixtures.py:21-26` said "**Three** of the eight are deliberate failures...
``not-noded`` and ``hole-in-hole`` are backend refusals". After 5c there are
**two**, and `not-noded` is the showcase. The inline comment at `:208-209` —
"the noder (5b) is what fixes it" — becomes a statement about a fix that has
landed.

**Its two breaklines get property bits.** Today both carry mask `0`
**Its two breaklines get property bits.** At `605a60c` both carry mask `0`
(`viz/fixtures.py:207,210`), while the C++ suite's fixture of the *same
geometry* gives them road and river (`test_noding_node.cpp:162-165`). Two
changed literals — `1` for the river bit and `2` for the road bit under
`DEFAULT_VOCABULARY`'s numbering — and the picture becomes the user's sentence:
a road crossing a river, in two colours, meeting at a constructed node.

`fixtures.py` writes the masks as literals with a comment, because `viz/` may
not import a vocabulary (`fixtures.py:193-195` already does exactly this for
`BREAKLINE`). Note that `test_noding_node.cpp:76-77` numbers them the other way
not import a vocabulary (`fixtures.py:193-195` at `605a60c` already does exactly this for
the `RIVER` fixture). Note that `test_noding_node.cpp:76-77` numbers them the other way
round — `bit(0)` is its road. That is not a defect to reconcile: **the C++ holds
no vocabulary at all**, by increment 7's ruling, and its test constants are
local names. It is written down here only so that nobody "fixes" the two into
Expand Down Expand Up @@ -901,7 +902,7 @@ is priced 50 % above the block it replaces for that reason.
### Gate D — 5c's own, and it is a budget with an obeyable seam

The one gate in this project's record that worked fired **before its suite
existed** (`05b-noder-driver.md:430-432`); the one that failed did so because the
existed** (`05b-noder-driver.md:434-435`); the one that failed did so because the
committed suite spanned the seam it would have had to cut. So Gate D is armed
with that property removed by construction:

Expand Down Expand Up @@ -1145,7 +1146,7 @@ lifetime or a reinterpret:
`InvalidSnapSpacing` rather than raising; a path-like argument in `pslg`'s
place is a `TypeError`, matching `build_pslg`'s boundary.

**The end-to-end acceptance criterion**, which is `05b-noder-driver.md:143-146`'s
**The end-to-end acceptance criterion**, which is `05b-noder-driver.md:145-148`'s
criterion with the call it was waiting for:

```sh
Expand Down Expand Up @@ -1284,7 +1285,7 @@ increment's PR, or not recorded:
number is dropped in favour of the test's name and a `grep`, per
`.claude/REQUIRED-READING.md` on resolved values. The paragraph's claim is
still true; only the citation expired.
* **`src_python/tin_engine/viz/fixtures.py:21-26`** — three deliberate failures
* **`src_python/tin_engine/viz/fixtures.py:21-26`** (at `605a60c`) — three deliberate failures
become two, and `not-noded` changes role.

**Not corrected, because 5b corrected it itself:** the "The C++ rows are *not*
Expand Down
5 changes: 3 additions & 2 deletions docs/increments/06-cdt-viewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -732,8 +732,9 @@ The unqualified present-tense form (`grep -nE '^\s*\*'` over
`src_python/tin_engine/viz/*.py` and `_core.pyi`, "prints exactly
`scene.py:203: *,`") stood here and is **false of the tree today**, in two
ways: `scene.py`'s line is now `:212`, and `svg.py` — which did not exist at
`995f258` — contributes two more real undercounted lines (`:313`, `:373`) plus
two docstring lines the pattern also matches. The figure above is unaffected,
`995f258` — contributes two more real undercounted lines plus two docstring lines the
pattern also matches (`grep -nE '^\s*\*' src_python/tin_engine/viz/svg.py`
lists them). The figure above is unaffected,
because it is increment 6's measurement of increment 6; a re-runnable claim that
silently starts measuring a later tree is not, which is why it is now anchored.
Factor 2.16 on the ~90 estimate. The composition is the interesting part, and it
Expand Down
8 changes: 7 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ dev = [
"pytest-cov>=4.1",
"hypothesis>=6.100",
"mypy>=1.10",
"ruff>=0.5",
"ruff~=0.16.8", # pinned: `ruff format --check` gates CI; style shifts per minor
]

[project.scripts]
Expand Down Expand Up @@ -112,6 +112,12 @@ src = ["src_python", "tests/python"]
# as evidence. The maintained benchmark is tools/bench.py (@perf), checked by mypy below.
exclude = ["legacy", "docs/benchmarks"]

[tool.ruff.format]
# docs/ holds design records whose code blocks are quotations, cited by line;
# the formatter would rewrite Python inside Markdown and break both. .claude/
# holds the push and governance hooks, edited only with Ola's yes.
exclude = ["docs/**", ".claude/**"]

[tool.ruff.lint]
select = ["E", "F", "I", "N", "UP", "B", "SIM", "RUF", "ASYNC"]
ignore = []
Expand Down
32 changes: 26 additions & 6 deletions src_python/tin_engine/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -700,7 +700,14 @@ def mesh(
)
label = dem.stem
dem_run = _dem_mesh(
dem, stride, delaunay, snap_spacing, tolerance, clock, domain, domain_crs,
dem,
stride,
delaunay,
snap_spacing,
tolerance,
clock,
domain,
domain_crs,
DEFAULT_START_MIN_ANGLE if start_min_angle is None else start_min_angle,
not no_constraint_feet,
)
Expand Down Expand Up @@ -973,7 +980,12 @@ def _dem_mesh(
else:
t0 = time.perf_counter_ns()
out = refine(
to_core(tile), run.mesh, edges, masks, tolerance=tolerance, min_angle_deg=min_angle,
to_core(tile),
run.mesh,
edges,
masks,
tolerance=tolerance,
min_angle_deg=min_angle,
constraint_feet=feet,
)
_refine_phases(clock, (time.perf_counter_ns() - t0) / 1e9, out)
Expand All @@ -989,11 +1001,19 @@ def _dem_mesh(
valid=out.valid,
)
refinement = Refinement(
tolerance, out.max_error, out.rounds, out.inserted, out.flips, out.uncovered,
out.carved, out.quality_inserted, out.quality_skipped, out.feet,
tolerance,
out.max_error,
out.rounds,
out.inserted,
out.flips,
out.uncovered,
out.carved,
out.quality_inserted,
out.quality_skipped,
out.feet,
)
quality_start = f"start min angle {_exact(min_angle)} deg" if min_angle > 0 else (
"start quality off"
quality_start = (
f"start min angle {_exact(min_angle)} deg" if min_angle > 0 else ("start quality off")
)
sentence = (
f"refined from DEM nodes, constrained Delaunay, tolerance {_exact(tolerance)} m, "
Expand Down
8 changes: 2 additions & 6 deletions src_python/tin_engine/io/ply.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,7 @@ def write_ply(
# wrote that second line into the header and exited 0.
bad = next((ch for ch in comment if ch < " " or ch == "\x7f"), None)
if bad is not None:
raise ValueError(
f"a comment may not contain control characters; got {bad!r}"
)
raise ValueError(f"a comment may not contain control characters; got {bad!r}")
# ASCII is the header's encoding, so a non-ASCII comment cannot be
# written. --crs is unvalidated free text by ruling 5 and a degree sign
# in a projection string is ordinary, so this is a refusal a caller
Expand All @@ -123,9 +121,7 @@ def write_ply(
# below.
if not comment.isascii():
bad = next(ch for ch in comment if not ch.isascii())
raise ValueError(
f"a comment must be ASCII; got {bad!r} in {comment!r}"
)
raise ValueError(f"a comment must be ASCII; got {bad!r} in {comment!r}")

blocks = [(_vertex_declaration(len(points)), _vertex_body(points, ascii))]
if faces is not None:
Expand Down
Loading
Loading