Skip to content

Docs coverage of the public surface + bidirectional pin (issue #176) - #214

Open
espg wants to merge 6 commits into
mainfrom
claude/176-docs-surface
Open

Docs coverage of the public surface + bidirectional pin (issue #176)#214
espg wants to merge 6 commits into
mainfrom
claude/176-docs-surface

Conversation

@espg

@espg espg commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Closes #176.

Docs coverage of the public surface, per the approved plan on the issue (approach (A), all five leans), re-derived against post-#195 main as the blocked-status note required: mortie.__all__ is now 88 names, and 13 of them rendered on no docs/api page — the pre-#195 eleven plus moc and toc, the issue #196/#198 callable constructor namespaces, which no members: roster ever listed.

Disposition table (re-derived on main @ 4900a7e, by object identity)

name(s) disposition
geo2uniq, norm2uniq, uniq2geo, unique2parent documented on docs/api/convert.md; the stale "Not yet documented here" admonition deleted (issue #136 closed and settled the signatures; heal_norm is gone from the tree)
ring_validity, ring_is_simple, RingValidity documented on docs/api/coverage.md, with an intro pointer to the Ring validity narrative
rank_to_xy, xy_to_rank new page docs/api/rank_xy.md + nav entry after orders (spec §8 linked as the normative statement)
moc, toc added to docs/api/moc_object.md / docs/api/toc_object.md members: — each renders as the module attribute with its "constructor, bound where the submodule used to be" docstring
MortonIndexType, MortonIndexExtArray allowlisted (LAZY_ARROW_UNDOCUMENTED) with the griffe/static-resolution reason inline; docs/arrow_interchange.md gains a real "The pyarrow extension classes" subsection, so docs/api/arrow.md's redirect finally points at something

Reverse direction (docs → surface): 4 documented names are not flat in __all__ and sit in the justified MODULE_SCOPED_DOCUMENTED roster keyed on (module, name) — mortie.arrow.export_c_array / export_c_schema / import_c_array (namespaced C Data Interface plumbing, issue #93) and mortie.morton_index.MortonIndexScalar (#104). Each hangs off a submodule that is itself in __all__, and the test asserts exactly that reachability.

Phases

  • Phase 1 — document the 11 documentable names (4 pages touched, 1 page added, nav entry, arrow_interchange.md subsection, stale admonition deleted) — 0bca837
  • Phase 2 — flip mortie/tests/test_docs_api_pages.py bidirectional — 044b03a

The bidirectional pin's shape

Three new tests beside the existing stale-entry direction (which is unchanged):

  • test_every_public_name_documented_on_exactly_one_page — every non-submodule __all__ name must appear on exactly one page, keyed on (defining module, name) by object identity (getattr(page_module, name) is getattr(mortie, name)), so the arrow skins of from_wkb / polygons_to_morton_mocs on arrow.md stay legal without double-counting the flat name; every submodule in __all__ (geometry, morton_index, arrow) must have a page whose ::: block is that module. The issue's acceptance mutation now fails: deleting - morton_buffer from buffer.md (the post-API consolidation: one polymorphic function per operation (issue #187) #195 equivalent of the children_of deletion in the PR #172 review finding) fails this test — verified by mutation locally.
  • test_every_documented_name_is_public — every members: entry must be in __all__ by name or in MODULE_SCOPED_DOCUMENTED; the roster itself is guarded against staleness (entries must still be on their page, and their submodule must be in __all__). Adding a bogus members: entry fails — verified by mutation.
  • test_lazy_arrow_roster_is_not_stale — if the two lazy Arrow classes ever become renderable and get listed, the allowlist must shrink in the same change; and they must stay in __all__. The lazy pair is never getattr-ed (resolving them without pyarrow raises ImportError, per the risk note on the issue).

The symmetric pages→__all__ flat-promotion direction is handled by the roster rather than by forcing the 4 module-scoped names into __all__ — see question (2) below.

Render-anchor verification (the RingValidity risk item)

The espg ruling asked whether griffe actually renders the namedtuple (and the two _MocNamespace()/_TocNamespace() instance attributes) — hasattr alone can't see a render-time drop. Checked manually (scripted grep over the mkdocs build --strict output, not automated in the suite): every one of the 11 newly documented names has its id="mortie.<module>.<name>" anchor in the built site, including RingValidity, moc, and toc. Automating it would put the docs toolchain into the test env; left manual, flagged in question (3).

Also in this PR

Phase 2's identity-keyed check exposed a pre-existing test leak: test_arrow.py::test_clear_error_without_pyarrow evicts mortie.arrow from sys.modules and re-imports a transient pyarrow-blocked copy — which rebinds the parent package's arrow attribute, and monkeypatch.undo() restores the sys.modules entry but not the attribute, leaving mortie.arrow is not sys.modules["mortie.arrow"] for the rest of the session (order-dependent failure under pytest-randomly). Fixed at the source with one monkeypatch.setattr(mortie, "arrow", ...) registration so undo() restores both.

Testing

  • pytest -v: 1872 passed, 16 skipped (full suite, including the new pins; also re-run with the docs-pin file ordered after test_arrow.py to confirm the leak fix).
  • Mutation checks: - morton_buffer deletion and bogus-member addition both fail the new tests (see above).
  • mkdocs build --strict: green; anchors verified as above.
  • flake8 mortie --select=E9,F63,F7,F82: clean. ruff check --select E,F,W,I --ignore E501 on the two touched Python files: clean. numpydoc lint: no non-test .py touched (tests are excluded from the gate).

Questions for review

  1. moc / toc placement. They render as attribute entries under their object pages' existing ::: block (docstring: "The Moc constructor, bound where the submodule used to be"). Alternative was allowlisting them as narratively-documented (the pages' prose already opens with mortie.moc(...)). The member entry seemed strictly better — say if you'd rather not have the attribute stubs rendered.
  2. The 4 module-scoped names (export_c_array, export_c_schema, import_c_array, MortonIndexScalar): kept module-scoped and rostered, per the standing lean that the symmetric direction is not forced. If any should instead be promoted flat into __all__ before the 1.0 freeze, that's a surface change and your call — the roster shrinks by the same entry either way.
  3. Automating the render-anchor check would mean building the site inside pytest (docs toolchain in the test env) or a docs-CI step that greps site/ for every documented member's anchor. Worth an issue, or leave manual?
  4. Scalar MortonWord type: packed word that reprs as the decimal label #152 sequencing: the original queue was API consolidation: one polymorphic function per operation (issue #187) #195Scalar MortonWord type: packed word that reprs as the decimal label #152 → this. Scalar MortonWord type: packed word that reprs as the decimal label #152 (MortonWord residual) is still open; when it lands a new public name, the bidirectional pin will fail until the name gets a page entry — which is exactly the designed behavior, just noting the order inverted per your in-session direction.

@espg espg added the implement label Aug 24, 2026
@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.44%. Comparing base (4900a7e) to head (86d92ba).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #214      +/-   ##
==========================================
- Coverage   96.53%   96.44%   -0.09%     
==========================================
  Files          20       20              
  Lines        2365     2365              
==========================================
- Hits         2283     2281       -2     
- Misses         82       84       +2     
Flag Coverage Δ
unittests 96.44% <ø> (-0.09%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
mortie/arrow.py 98.08% <ø> (-1.28%) ⬇️

Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 4900a7e...86d92ba. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread docs/arrow_interchange.md Outdated
The pyarrow skin's two classes are public as `mortie.MortonIndexType` and
`mortie.MortonIndexExtArray` (and on `mortie.arrow`), but they are **built
lazily on first attribute access** behind a module `__getattr__`, so that
importing mortie never imports pyarrow. That is also why they have no

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

“so that importing mortie never imports pyarrow” is false whenever pyarrow is installed, so the sentence states the opposite of the contract the code actually keeps. mortie/arrow.py ends with an eager registration block:

try:
    import pyarrow as _pa  # noqa: F401

    _build_type()
except ImportError:
    pass

and mortie/__init__.py does from . import arrow eagerly — so import mortie pulls pyarrow in and builds/registers the extension type at import time:

$ .venv/bin/python -c "import sys, mortie; print(\"pyarrow\" in sys.modules, mortie.arrow._EXT_TYPE is not None)"
True True

The next clause inherits the same problem: “the classes do not exist until first touched” — with pyarrow present the type object exists from import. What is true (and is the real griffe reason) is that the two names are never module-level attributes: they are locals of _build_type(), reachable only through arrow.__getattr__, so static resolution has nothing to render either way.

Fix direction: state the guarantee the code makes — importing mortie never requires pyarrow (a numpy-only install imports fine, and touching either name raises the curated ImportError) — and pin the no-render reason on “never bound as module attributes” rather than on “not built yet”.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Fixed in 9d66659 — the sentence stated the opposite of the contract, exactly as you show.

The subsection now pins the no-render reason on the binding, not on build timing: the classes are defined inside _build_type() and handed out by a module __getattr__, so they are never bound as module attributes and static resolution finds nothing to render whether or not pyarrow is installed. The optionality claim is re-scoped to what the code actually keeps — importing mortie never requires pyarrow (numpy-only install imports fine, touching either name there raises the curated ImportError) — and the eager block you quoted is now stated positively: when pyarrow is present, mortie.arrow builds and registers the extension type at import so a parquet read resolves the extension name without the user touching the type first.

The LAZY_ARROW_UNDOCUMENTED comment in mortie/tests/test_docs_api_pages.py already carried the accurate version of this reason ("defined inside _build_type() and reached only through arrow.__getattr__"), so the prose and the roster now say the same thing.

Comment thread docs/arrow_interchange.md Outdated
subclass: what `from_morton_index` returns, and what pyarrow hands back
when the registered type resolves on read. Its one addition over the
stock class is `to_numpy(**kwargs)`, which materializes the `uint64`
storage (defaulting `zero_copy_only=False` so a null-bearing array

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

“materializes the uint64 storage (defaulting zero_copy_only=False so a null-bearing array converts)” overpromises for exactly the case the parenthetical calls out: a null-bearing array does not convert to packed words, it converts to float64/NaN.

$ .venv/bin/python
>>> a = marrow.from_morton_index(MortonIndexArray(np.array([1, 0, 3], dtype=np.uint64)))
>>> a.null_count, a.to_numpy(), a.to_numpy().dtype
(1, array([ 1., nan,  3.]), dtype("float64"))

(the 0 word is MortonIndexArray._SENTINEL, mortie/pandas.py:140, so it lands as a null in the Arrow storage). zero_copy_only=False only stops it raising; pyarrow still widens an integer array with nulls to float64, which is lossy for 64-bit words. A reader following this bullet gets NaNs where the doc promised uint64.

The to_morton_index steer is correct — it does storage.fill_null(int(MortonIndexArray._SENTINEL)) (mortie/arrow.py:275) — but as written the sentence reads as “both paths give you words, one just maps nulls”.

Fix direction: say to_numpy() returns the uint64 words for a null-free array and float64/NaN once nulls are present, which is why to_morton_index is the path whenever nulls are possible. Worth syncing MortonIndexExtArray.to_numpy’s own docstring (mortie/arrow.py, “Returns … The uint64 packed words”), which has the same gap.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Fixed in f511cb6, and I took the docstring sync you flagged in the same commit (same finding).

The bullet now says the zero_copy_only=False default only stops the call raising: null-free storage comes back as the uint64 words, while any null present makes pyarrow widen the result to float64 with NaN — lossy for 64-bit words — which is why to_morton_index is the path whenever nulls are possible (it fills nulls with the sentinel 0 word and stays uint64).

MortonIndexExtArray.to_numpy’s own Returns section in mortie/arrow.py carried the same gap and now states the null-free / null-bearing split and points at to_morton_index.

Gates on that commit: pytest mortie/tests/test_arrow.py -q 25 passed; numpydoc lint mortie/arrow.py clean; flake8 mortie --select=E9,F63,F7,F82 clean.

Comment thread mortie/tests/test_docs_api_pages.py Outdated
def all_pages():
"""Map each documented module to its page name and ``members:`` roster."""
return {
module: (page.name, members)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

all_pages() keys the dict on the module, so if two pages ever document the same module the later-sorted page silently replaces the earlier one’s roster — the very mis-association page_members() refuses to tolerate within a page (“Fail loud here rather than mis-associate”) is silently allowed across pages, and it disarms the reverse direction.

Verified by copying docs/api to a scratch dir and repointing API_DIR at it (working tree untouched): added zz_coverage_split.md carrying a second ::: mortie.coverage block with coverage.md’s four members, and appended a non-public - np to coverage.md. All three new tests pass:

M3-two-pages-one-module: test_every_public_name_documented_on_exactly_one_page PASS
M3-two-pages-one-module: test_lazy_arrow_roster_is_not_stale PASS
M3-two-pages-one-module: test_every_documented_name_is_public PASS

whereas the same - np on coverage.md alone fails as designed:

M2-bogus-np: test_every_documented_name_is_public FAIL -> documented names missing from mortie.__all__: ["coverage.md: np"]

Three consequences of the collision: a stray / non-public members: entry on the losing page escapes the docs→__all__ direction entirely; a name genuinely rendered twice escapes the “exactly one page” rule; and when the surviving page does not repeat a member, the forward test reports "<name>: on no page" for a name that is in fact on a page — a misleading failure to debug.

Fix direction: key on the page ({page.name: (module, members)}) and derive the module lookup from the values, or add a loud assert that no module is claimed by two pages, mirroring the one-block-per-page guard just below.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Fixed in e9af4c4 — took the assert option, mirroring the one-block-per-page guard in page_members() rather than re-keying the dict (consumers stay as they are, and the failure names both pages).

all_pages() is now a loop that asserts a module is claimed by at most one page: zz_coverage_split.md: mortie.coverage is already documented by coverage.md.

Re-ran your escape scenario against a scratch copy of docs/api (working tree untouched, no stray files left): with a second ::: mortie.coverage page present, all three tests now fail on that assert instead of passing —

test_every_public_name_documented_on_exactly_one_page: FAIL -> zz_coverage_split.md: mortie.coverage is already documented by coverage.md
test_lazy_arrow_roster_is_not_stale:                   FAIL -> (same)
test_every_documented_name_is_public:                  FAIL -> (same)

Gates: pytest mortie/tests/test_docs_api_pages.py -q -p no:randomly 20 passed; ruff check --select E,F,W,I --ignore E501 and flake8 --select=E9,F63,F7,F82 on the file clean.

Comment thread docs/api/rank_xy.md
convention (origin at the subtree's south corner). The input is rank-space,
**not** packed morton words — strip the shard prefix down to the base-4
digit-tail rank first. Normative statement:
[specification.md §8](../specification.md#8-rank-space-x-y-deinterleave);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

This anchor (and ../coverage_methods.md#ring-validity on docs/api/coverage.md) resolves correctly today — I built the site and confirmed specification/index.html carries id="8-rank-space-x-y-deinterleave" and coverage_methods/index.html carries id="ring-validity" — but the PR’s “mkdocs build --strict: green” does not cover it: mkdocs 1.6 defaults validation.links.anchors to info (.venv/.../mkdocs/config/defaults.py:194), and mkdocs.yml declares no validation: block, so a wrong fragment builds clean.

That gap is already live in the tree, including in a file this PR edits:

$ grep -rn "#latitude-convention" docs/
docs/healpix_interchange.md:83 / docs/index.md:55 / docs/api/convert.md:15 / docs/specification.md:315,318
$ grep -o "id=\"[^\"]*latitude[^\"]*\"" site/specification/index.html
id="9-latitude-convention-authalic-on-wgs84"

Five links point at an anchor the built page does not have, and --strict has never said so — the same silent-drift shape this PR exists to close, one layer up from members:.

Fix direction: add validation: {links: {anchors: warn}} to mkdocs.yml so --strict actually pins the new cross-page anchors; that turns the five #latitude-convention links red, so either fix them in the same change or leave both for a follow-up issue — your call on scope, since the dangling ones are pre-existing.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Partial fix in 86d92ba, with the config change and the four other dangling links deliberately left standing.

(1) The anchors this PR adds are verified. Built the site and grepped the HTML: specification/index.html carries id="8-rank-space-x-y-deinterleave" and coverage_methods/index.html carries id="ring-validity", so both new cross-page links resolve today — confirming your check independently rather than resting on --strict, which as you show says nothing about fragments (mkdocs 1.6 defaults validation.links.anchors to info and mkdocs.yml declares no validation: block).

(2) Not turning on validation.links.anchors. Changing mkdocs.yml alters what the Docs build enforces repo-wide and would turn the five pre-existing #latitude-convention links red — a behavior change past this PR’s scope (documenting the public surface). That is a scope call for espg; worth its own issue alongside question (3) on the PR body, since both are "make the docs build assert what we verified by hand".

(3) Fixed the one link in a file this PR already edits. docs/api/convert.md:15 now points at ../specification.md#9-latitude-convention-authalic-on-wgs84 — the real rendered id, read off the built spec page (grep -o id="[^"]*latitude[^"]*" → only 9-latitude-convention-authalic-on-wgs84), not guessed. Rebuilt after: mkdocs build --strictgreen and the rendered href is../../specification/#9-latitude-convention-authalic-on-wgs84`.

The other four (docs/healpix_interchange.md:83, docs/index.md:55, docs/specification.md:315,318) are outside this PR’s file set and stay standing for espg — they are pre-existing and fixing them here would be an unrelated docs sweep.

@codspeed-hq

codspeed-hq Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 71 untouched benchmarks
⏩ 1 skipped benchmark1


Comparing claude/176-docs-surface (86d92ba) with main (4900a7e)

Open in CodSpeed

Footnotes

  1. 1 benchmark was skipped, so the baseline result was used instead. If it was deleted from the codebase, click here and archive it to remove it from the performance reports.

@espg
espg marked this pull request as ready for review August 24, 2026 23:31
@espg espg added the waiting label Aug 24, 2026
@espg

espg commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

🤖 from Claude

Status: all phases complete, all four adversarial-review findings folded (fix shas and rationale on each inline thread), and CI on 86d92ba is green on every substantive check — tests 3.10/3.11/3.12, ruff, numpydoc validation, arro3-no-pyarrow, msrv, crate, all wheel builds, codecov patch+project, Python benchmarks, Docs strict build. The one non-concluded check at flip time is CodSpeed's "Rust benchmarks" (in_progress) — the known-noise job on docs-heavy diffs (same job read cancelled/unstable on PR #195); noted, not chased. Marked ready for review and applied waiting — the remaining calls are the four "Questions for review" in the PR body plus the two items under "Standing for espg" on the anchor-validation thread.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Docs coverage of the public surface: triage the 11 undocumented __all__ names, then flip test_docs_api_pages.py bidirectional

1 participant