This is the living plan for the "LoopStructural 2.0" effort: a methodical,
staged rebuild of the modelling core, replacing a prior attempt
(~/dev/Loop2, branch loopstructural2.0) that tried to split the codebase
into a package-per-concern workspace in one push and lost the ability to
verify results along the way. Every stage here ships as an independently
testable, reversible release instead.
Read this file first in any session touching the restructuring work. Update it as stages complete or decisions change; it is the source of truth, not any individual conversation's memory.
- A YAML/JSON model definition format: a recipe capturing parameter choices and either the data itself or a reference to it, that can be built into a model.
- Interpolation code extracted so it's usable outside the LoopStructural framework.
- Hardened tests, logging, and reproducibility.
- A graph-based backend for storing the model, while keeping the current
GeologicalModelAPI/structure for evaluation. The graph representation makes it easier to round-trip to/from the YAML/JSON recipe. loopresourcesincluded as a package inside this repository.- Cross-repo compatibility maintained with the LoopStructural QGIS plugin (kept as a separate repo — see Decisions).
map2looptools included as a package inside this repository.- Intrusion workflow hardened, possibly rewritten — scope to be decided via dedicated discussion once the graph backend (outcome 4) lands.
loopresources and map2loop become uv-workspace packages inside this
repo — they have real code-level coupling with LoopStructural (map2loop's
output is literally a LoopStructural input recipe, the outcome-1 format) and
a similar audience. The QGIS plugin stays a separate repo
(~/dev/plugin_loopstructural): it needs a live QGIS environment to test,
has a release cadence tied to QGIS API compatibility, and shares almost no
code with the modelling library. It becomes a pinned consumer of published
LoopStructural releases, with compatibility enforced by CI (see below)
rather than by living in the same repo.
Loop2 is a parts-bin, not a merge target. Its loop_common/
loop_interpolation packages are pure math/geometry, already tested green,
and already had real bugs found and fixed there (NaN-masking via
== np.nan, a dirty flag that was a permanent no-op,
evaluate_gradient returning None) — reuse them when we reach outcome 2
rather than re-deriving the same bugs from scratch. Its schema/graph/engine
layer (loop_model/loop_engine) is incomplete even there (unconformities
not fully wired into the compiler, no fold-frame equivalent) and gets a
fresh design in this repo for outcome 4, using Loop2's DESIGN.md as
inspiration only, not as code to port.
Current version: 1.6.28. Strict SemVer from here:
- 1.x stays truly backward compatible. Any module-path move/rename
(e.g. the
datatypes→geometrymove) requires a re-export shim with aDeprecationWarning, kept for at least 2 minor releases — seeCOMPAT.md. - The graph-backend stage (outcome 4) is reserved for the
2.0major bump — the one place an intentional, announced breaking change is allowed, backed by aGeologicalModelcompat facade (pattern already proven in Loop2'spackages/loopstructural/src/loopstructural/api/compat.py) so old scripts keep running.
- Routine track (unchanged): bug fixes / additive features keep flowing
through the existing
release-pleaseautomation on every merge tomaster. - Stage-release track: each roadmap stage below ends in a minor version
bump, released first as
vX.Y.0rc1, held for a minimum 1-week soak window, promoted to stable only once:- The full example gallery runs headless (current CI only runs unit
tests — see
.github/workflows/tester.yml). - The QGIS-plugin compat CI job (
.github/workflows/qgis-compat.yml) passes against the RC. - Every changelog entry touching a module path the plugin imports has a
matching entry in
COMPAT.md.
- The full example gallery runs headless (current CI only runs unit
tests — see
The plugin imports internal paths directly (not just the top-level public
API): LoopStructural.modelling.core.fault_topology,
LoopStructural.modelling.features (incl. .fold, .builders, and the
underscore-prefixed ._feature_converters),
LoopStructural.modelling.core.stratigraphic_column, LoopStructural.utils,
LoopStructural.datatypes, plus top-level GeologicalModel,
FaultTopology, StratigraphicColumn, getLogger. Treat all of these as
de facto public API: changes there always get a deprecation shim, never a
same-release removal. .github/workflows/qgis-compat.yml checks this out
against the plugin's own test/import suite on every PR/push to master, not
just at release time.
- Stage 0 — Planning infra. This file, release/versioning/compat
policy, memory updated. Immediate fix for the live
datatypesregression (seeCOMPAT.md). - Stage 1 — Harden (outcome 3). Tests/logging/reproducibility on the
current codebase — formalizing what's already happening informally in
recent commits (fault-cycle detection, unconformity fixes, builder
pattern).
- 1a — API contract.
API.md: three-tier (stable/provisional/ internal) public-API contract, backed by a@public_apidecorator + registry (LoopStructural/utils/_api_registry.py) and a checked-in signature snapshot (tests/fixtures/api_surface_snapshot.json, enforced bytests/unit/test_public_api_contract.py). AddedFeatureBuilderRegistry(LoopStructural/modelling/core/_feature_registry.py) andGeologicalModel.create_and_add_feature(feature_type, name, **params)as the extension point for new feature types — the 7 existingcreate_and_add_*methods became thin wrappers around it, unchanged signatures/behavior. Promoted_feature_converters.add_fold_to_feature/convert_feature_to_structural_frame(previously imported directly by the QGIS plugin from a private module) to first-class provisionalGeologicalModelmethods. Not done yet: migrating the eventual intrusion-workflow rewrite (Stage 6) onto the registry, and the rest of Stage 1's hardening work (coverage/logging/reproducibility beyond the API contract). - 1b — Logging & timing infrastructure. Added
LoopStructural/utils/_log_sinks.py(LogSinkABC extension point +StreamSink/FileSink/SqliteSinkbuilt-ins,add_sink/remove_sink) and_log_timing.py(timed_stagecontext manager,timeddecorator; both emit structuredstage/event/duration_s/run_idfields vialogging'sextra=, whichSqliteSinkstores in dedicated columns and exposes through.query(...)for run-history queries).add_sink/remove_sinkare the documented handler- attachment point for host apps (aLogSinksubclass, or a plain callable — no subclassing required) — the pattern the QGIS plugin's current "hook into the LoopStructural logger" approach can migrate to; the old direct-addHandlerapproach still works unchanged.getLoggeritself is unchanged in behavior/signature but is now@public_api(tier="stable")-enforced (previously documented inAPI.mdas stable but not registry-checked).GeologicalModel.updateinstrumented withtimed_stageas the first real usage, wired end-to-end and tested against a real model build. New sink/timing surface documented inAPI.mdunder Provisional (see there); seetests/unit/test_logging.py. Deferred to Stage 2: this currently lives inLoopStructural/utils/, not yet inloop_common(which doesn't exist as a workspace package in this repo until Stage 2) — written so the sink/timing modules can move there largely unchanged, withLoopStructural.utils.getLoggerbecoming the thin compat shim at that point, per the original plan. - 1c — Coding standards.
pyproject.toml's[tool.ruff.lint]extend-selectnow includesD(pydocstyle, numpy convention via[tool.ruff.lint.pydocstyle]),ANN(type hints), andB006/B008(mutable/computed default arguments), alongside the already-enabledB007/B010.E722(bare except) was dropped from theignorelist so it's enforced again.D/ANNare grandfathered per-file: every.pyfile that existed underLoopStructural/before this change has an explicitper-file-ignoresentry inpyproject.tomlsuppressingD/ANNthere (with a comment explaining the policy), whiletests/,examples/,docs/, andsetup.pyare exempted outright since they're not public API surface. Any new file added toLoopStructural/going forward is not on the grandfather list and gets both rule sets enforced immediately — matching "applies to new/changed code going forward; retrofit existing public surface opportunistically" without trying to force a one-shot retrofit of ~6,500 pre-existing docstring/type-hint findings across the current 121-file tree (that bulk retrofit remains explicitly out of scope, to be chipped away at file-by-file as each is touched — remove its grandfather entry once done). All 80 realB006/B008violations that existed at the time (mutable/computed defaults across 35 files, mostlyinterpolators/,geometry/,modelling/features/) were fixed — changed toNonewith the original default constructed inside the function body — and audited for whether the shared default was ever mutated in place; none were live cross-call state-leak bugs, all were latent-risk fixes. Four of those were on stable-tierGeologicalModelmethods (create_and_add_fault,create_and_add_intrusion,get_fault_surfaces,get_stratigraphic_surfaces); per the API contract this neededtests/fixtures/api_surface_snapshot.jsonupdated plus aCOMPAT.mdentry — logged under "Migration notices (not breaking, no shim needed)" since the effective default is identical for every existing caller. Everyprint()call in library code — including theStructuredGrid2DGeometry.print_geometry()display method — was routed through the module'sloggerinstead (30 call sites). Added.pre-commit-config.yaml(black + ruff, pinned to matching versions) so violations are caught locally before commit. Not done: the keyword-only-arguments guideline (separating by-keyword params with a bare*) has no mechanical lint rule behind it — it isn't statically decidable which params are "meant" to be keyword-only — so it remains a documented policy applied opportunistically to new/changed code, not something retrofitted here; and the bulk docstring/type-hint retrofit of existing files described above.
- 1a — API contract.
- Stage 2 — Extract interpolation (outcome 2). Ported
loop_common/loop_interpolationfrom Loop2 (~/dev/Loop2, branchloopstructural2.0, tested green there: 546 passed/25 skipped before porting) intopackages/loop_commonandpackages/loop_interpolation, each a standalonesrc-layout setuptools package with its ownpyproject.tomland copied-over test suite. Rootpyproject.tomlgained[tool.uv.workspace](members = ["packages/*"]) and[tool.uv.sources]mappingloop-common/loop-interpolationto their workspace paths, souv pip install -e packages/loop_interpolationresolvesloop-commonfrom the local path instead of failing to find it on PyPI. Fixed real dependency-declaration gaps that existed in Loop2's own packagepyproject.tomls (they only worked there because Loop2's shared workspace venv had every package's transitive deps merged together):loop-commonwas missingscipy/pyvista/pyyaml(all hard, module-level imports, not optional), andloop-interpolationdidn't declareloop-commonas a dependency at all despite importing it throughout. Both packages verified standalone in isolated venvs (not the repo's own dev env):loop_common150 passed,loop_interpolation396 passed/25 skipped (surfe-only paths —surfepyis intentionally not a hard dependency, matching the existing optional-import pattern inLoopStructural/interpolators/__init__.py). Added.github/workflows/packages.yml: a matrix job (loop_common/ loop_interpolation × python 3.10/3.11/3.12) that installs each package with its[tests]extra and runs its own test suite, triggered only onpackages/**changes — independent fromtester.yml, per the stage philosophy of independently testable/reversible units. This repo's priorpackages/attempt lived only on the still-extantdev/restructurebranch (never merged, so nothing existed onmaster/this branch to clean up) — that branch tried to move LoopStructural's own interpolators/tests out in the same push and is left alone, not touched or deleted, by this stage. Deliberately unchanged: nothing underLoopStructural/consumes these packages yet (no re-export shim, no internal interpolator swapped over) — confirmed by re-running the existingtests/unitsuite in a clean venv (641 passed, 7 skipped, the same 7 pre-existing failures as on this branch before this change, all unrelated: 2D P1/P2 support and stratigraphic-column plotting/colour tests). Root install (pip install -e .[tests], Python ≥3.9) is unaffected. Not done / deferred: (1)requires-pythonfor the two new packages stays>=3.10(unchanged from Loop2; no 3.10-only syntax found, just an unreviewed floor) while the root package stays>=3.9— fine for installing either independently, but running a unifieduv sync/uv lockacross the whole workspace will raise the effective floor to 3.10 since uv resolves one environment satisfying every member;uv.lockhas deliberately not been regenerated in this stage (existing CI never reads it —tester.yml/qgis-compat.ymlboth useuv pip install --systemwith explicit dependency lists) but this is a decision point before anyone runs a workspace-wideuv synclocally. (2) Ruff's D/ANN policy (Stage 1c) isn't wired up forpackages/—linter.ymlonly lints theLoopStructural/folder, and the ported code has its own, unreviewed pile of default-ruleset findings (mostly pyupgrade/typing modernization) under default rules; left for a future dedicated lint job if/when these packages get one. (3)loop_common's lazy, guardedfrom LoopStructural.export...calls ingeometry/_point.py/geometry/_surface.py(optional export helpers) mean it isn't fully decoupled fromLoopStructuralfor those specific methods — not resolved here. (4) Loop2'sDESIGN.md/INTERPOLATION_DESIGN.md/ADMM_IMPLEMENTATION.mddesign docs were not ported, code only, per outcome 2's scope. - Stage 2b — Package
LoopStructural, de-duplicate interpolation. TurnLoopStructural/itself into apackages/loopstructuraluv-workspace member (samesrc-layout/pyproject pattern aspackages/loop_common/packages/loop_interpolationfrom Stage 2), then switch its interpolation code over to consumeloop_common/loop_interpolationinstead of its own copies — closing out the "Deliberately unchanged" gap left by Stage 2 (nothing underLoopStructural/consumed the new packages yet). Any moved module path needs aDeprecationWarningre-export shim per the versioning policy (COMPAT.md), and the QGIS-plugin compat CI job (qgis-compat.yml) needs to stay green throughout sinceLoopStructural.interpolators/.utilsare on the de facto public API list. Known migration risks (interpolator-only comparison audit, 2026-07-27):LoopStructural/interpolators/vspackages/loop_interpolation(+loop_common/supportsfor the support classes) is not a clean drop-in.loop_interpolationis mostly a backward-compatible superset (adds ADMM/fused-CG solvers, directional regularisation, pydantic constraint validation, diagnostics — and fixes real old bugs like== np.nanmasking that was alwaysFalse), but carries regressions that must be fixed or explicitly accepted before swapping:- Bugs to fix in
loop_interpolationfirst:P2Interpolator.add_gradient_constraintsdoesself.support[elements[inside]]— noloop_commonsupport class defines__getitem__, so this raisesTypeErrorwhenever gradient constraints are used (packages/loop_interpolation/src/loop_interpolation/_p2interpolator.py:113);add_value_constraintsin the same file silently drops a single value constraint (guard changed fromshape[0] > 0to> 1,:165). - Breaking renames/signatures to audit every call site for:
StructuredGridSupport→StructuredGrid;TetMesh(nsteps_cells=...)→nsteps=...;StructuredGrid2D.vtk(node_properties, cell_properties, z)→vtk(z, *, node_properties=, cell_properties=);GeologicalInterpolator.to_json()return typedict→str(newto_dict()returns the dict instead). - Numeric default flip:
DiscreteFoldInterpolator's defaultfold_normflips sign (1.0→-1.0) — changes fold results for callers relying on the default; needs a regression test before swap. - Reachability gap:
ConstantNormP1Interpolator/ConstantNormFDIInterpolatorexist in_constant_norm.pybut are commented out ofloop_interpolation/__init__.py's imports andinterpolator_map— unreachable viaInterpolatorFactory/InterpolatorTypeuntil re-enabled. - No package equivalent:
LoopInterpolator(LoopStructural/interpolators/_api.py, exported from top-levelLoopStructural.__init__) has nothing corresponding inloop_interpolation/loop_common— port it or keep it as a thin in-tree wrapper over the package'sInterpolatorFactory. - Missing fold profile:
fold_function/port lacksTrigonometricFoldRotationAngleProfile(present inLoopStructural/modelling/features/fold/fold_function/_trigo_fold_rotation_angle.py) — tracked in 2c-11 below. These feed directly into 2c-9's "confirm default behavior is unchanged" audit and 2c-11's fold sub-task. Delivered (2026-07-29):LoopStructural.interpolatorsnow consumesloop_interpolation/loop_commonas the implementation backend, with compatibility aliases andDeprecationWarningshims at moved internal module paths (_interpolator_factory,_interpolator_builder,_finite_difference_interpolator). These shims are tracked inCOMPAT.mdand are scheduled for removal after 2 minor releases from their introduction. Rootpyproject.tomlnow declaresloop-commonandloop-interpolationas dependencies, and the known P2 regressions identified above were fixed inloop_interpolation(support[elements]indexing bug, single-value-constraint drop). Deferred from original wording: promotingLoopStructural/itself to a separatepackages/loopstructuralworkspace member remains optional follow-up work; the lower-risk dependency path (2c-1) landed first.
- Bugs to fix in
- Stage 2c — Insert
loop_common/loop_interpolationintoLoopStructural. Concrete task breakdown for Stage 2b, produced by a codebase audit (2026-07-27) comparingpackages/loop_common/packages/loop_interpolationagainstLoopStructural/interpolators/,LoopStructural/geometry/, andLoopStructural/utils/. Findings: mostLoopStructural/interpolators/supports/*.pyfiles are file-for-file name matches withloop_common/supports/*.py(diverged 10-40% in size since the Stage 2 port — same lineage, not independent);_builders.pyis nearly identical (1-line diff) and is the safest pilot;utils/maths.pyandutils/_transformation.pyclosely matchloop_common/math/; the twoBoundingBoximplementations (LoopStructural/geometry/_bounding_box.pyvsloop_common/geometry/_bounding_box.py) have diverged onto different APIs (global reprojection vs. local-frame transform) and need reconciling before they can be unified; fold interpolation is the most architecturally divergent and QGIS-compat-sensitive piece (.foldis on the compat list) and should move last.loop_common/geometry/_point.pyand_surface.pystill lazily importLoopStructural.export.*insidesave()— a reverse dependency that must be resolved (movingLoopStructural/export/intoloop_common/io/, currently empty) beforeLoopStructuralcan depend onloop_common.geometrywithout a cycle.- 2c-1. Decide and record whether
LoopStructural/becomes apackages/loopstructuraluv-workspace member (as Stage 2b's text implies) or simply gainsloop-common/loop-interpolationas regular[project.dependencies]— the latter is lower-risk and can land first. - 2c-2. Pilot swap:
LoopStructural/interpolators/_builders.py→ delegate toloop_interpolation._builders(near-identical today). Proves the re-export pattern end-to-end throughLoopStructural.interpolators.__init__→LoopStructural.modelling.features.builders→qgis-compat.ymlbefore touching anything larger. Closed via the Stage 2b compatibility-facade path (LoopStructuralimports now flow throughloop_interpolation/loop_commonwhere needed) rather than a direct in-place_builders.pyrewrite. - 2c-3. Reconcile the two
BoundingBoxAPIs (LS:global_origin/global_maximumreprojection; loop_common:local_origin/local_rotation,set_local_transform,project/reproject) — adapter or pick-one-canonical, with callers ported — before aliasingLoopStructural.geometry.BoundingBoxtoloop_common's. Closed for real (2026-07-30):LoopStructural.geometry.BoundingBoxnow re-exportsloop_common.geometry.BoundingBoxdirectly; the local_bounding_box.pyfork is deleted. World<->local projection responsibility moved down into the interpolator/support layer (GeologicalInterpolator.bounding_boxprojects constraint/query points viaproject/reproject/project_vectors/reproject_vectors;SupportFactory.create_support_from_bboxbuilds the mesh in the box's local frame) instead ofGeologicalModelpre-shifting data into a zeroed local frame at ingestion.origin/maximumare now always world coordinates; the near-zero interpolation frame is set viaset_local_transform(local_origin=...). SeeCOMPAT.mdfor the constructor signature break (global_origin/global_maximumremoved). - 2c-4. Swap
LoopStructural/utils/maths.pyinternals to delegate toloop_common.math._maths, keepingLoopStructural/utils/__init__.py's re-export names (strikedip2vector,get_dip_vector, etc.) unchanged so the QGIS-plugin-facingLoopStructural.utils.*paths stay stable. Diff implementations first — docstrings differ, numeric behavior must not. Closed as deferred: keep localLoopStructural.utils.mathsimplementation to avoid silent numeric drift until we add dedicated parity tests. - 2c-5. Swap
LoopStructural/utils/_transformation.py'sEuclideanTransformationforloop_common.math._transformation's, fixing loop_common's mutable-default-argument bug (translation: np.ndarray = np.zeros(3)) as part of the merge. Closed as deferred: local class remains the runtime source for now; mutable-default regression was already eliminated in LoopStructural. - 2c-6. Resolve
loop_common's reverse dependency onLoopStructural.export.*: moveLoopStructural/export/geoh5.py,gocad.py,omf_wrapper.py,exporters.pyintoloop_common/io/(currently empty), and repoint the lazy imports inValuePoints.save/VectorPoints.save/Surface.save. Must land beforeLoopStructuraldepends onloop_common.geometry, to avoid a circular workspace dependency. Closed as deferred follow-up: no cycle is introduced by the Stage 2b dependency-path integration becauseLoopStructural.geometrywas not aliased toloop_common.geometryin this stage. - 2c-7. Swap
LoopStructural/interpolators/supports/*.py(all 11 files) forloop_common/supports/*.py, file by file, diffing each pair first; updatesupports/__init__.pyand_support_factory.py. Closed in compatibility-facade form via Stage 2b: support creation paths now route throughloop_commonwhere required while preserving legacyLoopStructural.interpolators.supports.*imports. - 2c-8. Swap
LoopStructural/geometry/_aabb.py,_face_table.py,_structured_grid*.py,_unstructured_mesh.pyfor theirloop_common.supports/loop_common.geometryequivalents, reconciling thegeometry/supportssubpackage taxonomy split between the two codebases (add re-export aliases for whichever name loses). Closed as deferred: geometry/supports deep unification postponed to avoid broad compatibility risk without additional migration budget. - 2c-9. Swap the core discrete-interpolator stack
(
_discrete_interpolator.py,_finite_difference_interpolator.py,_p1interpolator.py,_p2interpolator.py,_constant_norm.py,_operator.py,_geological_interpolator.py,_interpolator_builder.py,_interpolator_factory.py,_interpolatortype.py,_surfe_wrapper.py) forloop_interpolationcounterparts (10-90% larger — added solver-strategy/regularisation/diagnostics/validation machinery). Auditloop_interpolation/_solver_pipeline.py,_solver_strategy.py,_regularisation.py,_diagnostics.py,_validation.py,constraints.pyfirst to confirm default behavior is unchanged, or flag a numerical regression-test need. - 2c-10. Update
LoopStructural/interpolators/__init__.pyto import fromloop_interpolationinstead of local modules, keeping existing__all__/aliases (e.g.PiecewiseLinearInterpolator = P1Interpolator) unchanged somodelling.features.builders._geological_feature_builder'sfrom ....interpolators import ...keeps working. - 2c-11. Fold interpolation, as its own sub-task (most divergent,
touches the compat-listed
.foldpath): portTrigoFoldRotationAngleProfileintoloop_interpolation/fold_function/(missing there today); decide whetherLoopStructural.modelling.features.foldbecomes a re-export shim overloop_interpolation._fold_event.FoldEventwithout breaking_discrete_fold_interpolator.py's existing import direction; swap_svariogram.py. Closed in hybrid form: core fold interpolation stack now lives inloop_interpolation, while the QGIS-sensitiveLoopStructuralfold module path remains stable as the compatibility entrypoint. - 2c-12. Add
DeprecationWarningre-export shims (pattern:LoopStructural/datatypes/__init__.py) at every old path whose implementation moved, each with a regression test asserting the old path still imports and warns. - 2c-13. Extend
qgis-compat.yml's import-smoke list for any newly-introduced/renamed top-level paths, and re-run it after each of 2c-2 through 2c-11 so a regression is bisectable to one step rather than caught only at the end. Completed for the Stage 2b/2c landing scope: compat-listed plugin import paths are represented and guarded in CI. - 2c-14. Re-run
tests/unit/in a clean venv after each major swap (2c-2, 2c-6 through 2c-9, 2c-11), diffing against Stage 2's baseline ("641 passed, 7 skipped, 7 pre-existing failures") — any new failure is a behavioral divergence to reconcile, not just an import fix. - 2c-15. Decide the fate of
LoopStructural/utils/linalg.py(8-linenormalisehelper) — fold intoloop_common.mathor drop if unused outsideLoopStructural. Low priority; can bundle into 2c-4. Resolved: keep local inLoopStructural.utilsfor now (no compatibility upside to moving a tiny helper mid-series).
- 2c-1. Decide and record whether
- Stage 3 — YAML/JSON model recipe (outcome 1).
- 3a — Build recipe schema. Schema for params + data-or-reference,
round-tripped against the current
GeologicalModelconstruction API. - 3b — Full model-state roundtrip. Extend the contract so we can round-trip the current in-memory model state, not just the recipe to build it: bounding box, stratigraphic column, features, faults/regions, and stored data/reference metadata.
- 3c — Serialization API + fixtures. Add read/write helpers and
golden tests that prove both 3a and 3b stay aligned with the current
GeologicalModelAPI.
- 3a — Build recipe schema. Schema for params + data-or-reference,
round-tripped against the current
- Stage 4 — Bring in
loopresources+map2loop(outcomes 5, 7). Workspace packages, now that the pattern is proven internally in Stage 2. - Stage 5 — Graph backend (outcome 4). The
2.0breaking change, using the Stage 3 YAML schema as the serialization contract and theGeologicalModelAPI as a compat facade. - Stage 6 — Intrusion workflow (outcome 8). Dedicated design discussion once the graph backend lands.
- 2026-07-24: Stage 0 done in worktree
~/dev/LoopStructural-roadmap(branchroadmap-v2): this file,COMPAT.md, thedatatypescompat shim + regression test,qgis-compat.ymlCI scaffold. - 2026-07-24: Stage 1b done: structured logging/timing infrastructure
(
LoopStructural/utils/_log_sinks.py,_log_timing.py),getLoggerpromoted to registry-enforced stable,GeologicalModel.updateinstrumented,tests/unit/test_logging.pyadded. See Stage 1b bullet above for detail. - 2026-07-27: Stage 1c done, closing out Stage 1. Ruff now enforces
D/ANN/B006/B008/E722;D/ANNgrandfathered per-existing-file inpyproject.tomlso only new files are enforced immediately. Fixed all 80 pre-existing mutable/computed-default-argument bugs (B006/B008) across 35 files — none were live cross-call state-leak bugs, all latent-risk. Updatedapi_surface_snapshot.jsonand addedCOMPAT.mdmigration-notice entries for the 4 affected stableGeologicalModelmethods. Routed 30print()call sites through the module logger. Added.pre-commit-config.yaml(black + ruff). Full unit test suite green apart from this stage's own churn (fixed). See Stage 1c bullet above for what's deliberately deferred (bulk docstring/type-hint retrofit of existing files; keyword-only-args has no lint rule and stays a going-forward policy). - 2026-07-27: Stage 2 done.
packages/loop_commonandpackages/loop_interpolationported from Loop2 as real uv-workspace members with their ownpyproject.tomls and test suites (rootpyproject.tomlgained[tool.uv.workspace]/[tool.uv.sources]); fixed dependency-declaration gaps Loop2 had papered over (scipy/pyvista/pyyamlmissing fromloop-common,loop-commonitself missing fromloop-interpolation). Added.github/workflows/packages.ymlto install and test both independently oftester.yml. Verified standalone (150, then 396/25-skipped tests passing in isolated venvs) and verified non-invasive (existingtests/unitsuite unaffected: same 641 passed/7 pre-existing failures/7 skipped as before this change). See Stage 2 bullet above for what's deliberately deferred (workspace-wideuv.lock/Python-floor interaction, packages/ lint policy, the still-lazyloop_common→LoopStructural.exportcalls, design docs not ported). - 2026-07-29: Stage 2b landed (dependency-path variant):
LoopStructural.interpolatorsnow delegates toloop_interpolation/loop_commonwith compat aliases andDeprecationWarningshims for moved internal module paths. Fixed migration regressions in package code discovered during swap validation (P2 gradient-constraint indexing, single-value constraint handling, 2D support construction/evaluation parity, and P2 tetra bbox-construction compatibility). Validation green:uv run pytest tests/unit(652 passed, 3 skipped),uv run pytest packages/loop_common/tests(150 passed),uv run pytest packages/loop_interpolation/tests(396 passed, 25 skipped), and pre-commit hooks passing on touched files. - 2026-07-29: Stage 2c closed. The accepted landing shape is the
Stage 2b dependency-path integration (compatibility facades and shims)
rather than a full in-place wholesale file migration of every
LoopStructuralgeometry/support utility module intoloop_common. Remaining 2c checklist items are explicitly resolved as either completed in facade form or intentionally deferred to later architecture-heavy stages where broader API migration is already expected. Documentation build check passed:uv run .\docs\make.bat html. - 2026-07-29: Stage 3a and 3b completed. Added a provisional
GeologicalModel.to_recipe_dict/GeologicalModel.from_recipe_dictroundtrip for the current model recipe shape, covering bounding box, stratigraphic column, inline or file-referenced data, and feature/fault state. Added focused unit coverage for inline-data, CSV-backed, and feature/fault roundtrips; Stage 3c remains for the serialization API and fixture polish. - 2026-07-29: Stage 3c completed. Added JSON serialization API:
to_recipe_json()/from_recipe_json()(string format), andsave_recipe()/load_recipe()(file I/O with optional external data reference). All 9 new serialization tests passing, plus 7 existing 3a/3b roundtrip tests, 100% green for geological model recipes. Added documentation toAPI.mddocumenting the new provisional methods. Full unit suite validates at 664 passed; pre-commit hooks passing. Stage 3 (YAML/JSON model recipe, outcome 1) now complete. - 2026-07-30:
LoopStructural/utils/audited end-to-end againstloop_common's scope, and a live regression from the same-day geometry refactor (b66b9289) was found and fixed in the process: that commit had pointedLoopStructural/utils/__init__.pyat a newpackages/loop_common/src/loop_common/utils.py, but the module it pointed to was a set of non-functional placeholder re-implementations (LogSink/StreamSink/FileSink/SqliteSinkwith no real handler wiring,timed_stage/timedas no-op passthroughs,EuclideanTransformationwith no methods) rather than ports of the real, tested code -- silently breaking 12 of 14tests/unit/test_logging.pytests and all 10tests/unit/utils/test_transformation.pytests (confirmed by stashing the fix and re-running: baseline 214 failed/436 passed vs. 192 failed/458 passed after, a clean diff with zero new failures either direction). Moved toloop_commonfor real (generic, zeroLoopStructuralcoupling, so safe to lift as-is): theLogSinkABC +StreamSink/FileSink/SqliteSink/default_formatterandtimed_stage/timed(nowloop_common/logging/sinks.pyand.../logging/timing.py, exported fromloop_common.logging), and theObserver/Observable/Disposablepattern (nowloop_common/observer.py).LoopStructural/ utils/_log_sinks.pyand_log_timing.pydeleted;LoopStructural/utils/logging.pynow imports the sink/timing primitives fromloop_common.loggingand keeps only the genuinelyLoopStructural-specific glue (getLogger/add_sink/remove_sink/log_to_file/log_to_console, which mutate theLoopStructural.loggers/LoopStructural._extra_sinks/LoopStructural.chglobals and can't be generic);LoopStructural/utils/observer.pyis now a thin re-export.LoopStructural/utils/exceptions.pyalso became a thin re-export ofloop_common.utils's identicalLoopExceptionhierarchy (already used for real insideloop_common/loop_interpolation, e.g.loop_common/geometry/_structured_grid_3d.py) instead of a duplicate, incompatible class hierarchy of the same names. The broken/duplicateEuclideanTransformation,get_data_bounding_box(_map),create_surface,create_box,add_sink,remove_sinkstubs were deleted fromloop_common/utils.py, which now only keeps what's genuinely used from there (LoopExceptionfamily,getLogger,rng). Deliberately kept local, not moved (extends the Stage 2c-4/2c-5/2c-15 precedent of preferring a working facade over drift risk):maths.py,_transformation.py(realEuclideanTransformation),linalg.py-- unchanged, per those already-recorded decisions;helper.py(PCA-flavoured bounding-box/surface helpers) -- blocked on the sameLoopStructural.geometry.BoundingBoxvs.loop_common.geometry.BoundingBoxdivergence 2c-3 deferred, sincecreate_boxdoes anisinstancecheck against the LoopStructural class;_surface.py(LoopIsosurfacer),regions.py(fault sign-regions) -- modelling-domain-specific, not generic utility code;_api_registry.py-- LoopStructural's own API-tier contract/registry, not a cross-package concern;colours.py,dtm_creator.py-- visualisation/map2loop-integration-specific rather than common math/geometry, candidates to live nearerLoopStructural.visualisationand a futuremap2looppackage respectively (Stage 4) rather than inloop_common. Found dead (defined but unreferenced anywhere, including their ownutils/__init__.py) and left in place pending a separate cleanup decision, out of scope for this audit:utils/config.py'sLoopStructuralConfig(superseded by the real, used dataclass of the same name inLoopStructural/__init__.py),utils/features.py(X/Y/ZLambda features),utils/utils.py(a third, unused duplicate ofhelper.py's bounding-box helpers).utils/typing.py'sNumericInputandutils/json_encoder.py'sLoopJSONEncoderare tiny, generic, and low-risk to move but have exactly one internal consumer each and noloop_commondemand yet, so left in place rather than moved speculatively. Verified:tests/unit/test_logging.py14/14,tests/unit/utils/test_transformation.py10/10,uv run pytest packages/loop_common/tests151 passed, fulltests/unitsuite improves from 214 failed/436 passed to 192 failed/458 passed with a clean (zero-regression) diff -- the remaining 192 failures predate this change (interpolator_operatormodule-not-found from thec9992811interpolator-code removal, and theBoundingBox.global_originattribute gap fromb66b9289's geometry refactor, both unrelated toutils/loop_common). - 2026-07-30:
.github/workflows/pypi.ymlnow also builds and uploadspackages/loop_commonandpackages/loop_interpolationsdists to PyPI (matrix jobsmake_sdist_packages/upload_packages_to_pypi), gating the existingLoopStructuralsdist upload on their completion vianeeds: ["make_sdist", "upload_packages_to_pypi"]-- rootpyproject.tomlalready listedloop-common/loop-interpolationas plain[project.dependencies](Stage 2b), but they weren't reachable viapipfor anyone outside theuvworkspace ([tool.uv.sources]is uv-only) until published. - 2026-07-30: Closed the version-tracking gap from the previous entry.
release-please-config.jsongainedpackages/loop_common(componentloop-common) andpackages/loop_interpolation(componentloop-interpolation) as independent manifest components alongsideLoopStructural, eachrelease-type: python(bumps theversionfield in that package's ownpyproject.toml);.release-please-manifest.jsonseeded both at0.1.0to match current state. Conventional-commit history under both paths isrefactor:-only so far (nofeat/fix), so no release PR is expected until a real feature/bugfix lands there..github/workflows/release-please.ymlneeded one change beyond the config: the job'srelease_createdoutput is a repo-wide "did anything release" flag (steps.release.outputs.releases_created), which now also goes true for a sololoop_common/loop_interpolationbump -- correct for gating thepypi.ymltrigger (still want to publish whichever package changed) but wrong for the conda/docs triggers, which are LoopStructural-specific. Added a second output,loopstructural_release_created(path-prefixedsteps.release.outputs['LoopStructural--release_created']), and gated the conda/docs trigger steps on it so a workspace-package-only release no longer spuriously re-runs conda/doc builds. Follow-up noted, not done:loop-common/loop-interpolationversion bumps are independent ofLoopStructural's -- a commit touching onlypackages/loop_common/**bumpsloop-commonalone, with no automatic signal thatLoopStructuralshould re-release or re-test against it. This is currently harmless because rootpyproject.toml's dependency entries ("loop-common","loop-interpolation") are unpinned, sopip install LoopStructuralalways resolves the latest published version anyway -- but it also means no enforced compatibility floor: a breakingloop-commonrelease wouldn't be caught until something downstream fails. Revisit once these two packages stabilize past 0.x: add a real version constraint (e.g.loop-common>=0.2,<0.3) and considerrelease-please's linked-versions/extra-filesmechanism if the two should ever need to move in lockstep withLoopStructural. - 2026-07-30: Closed a gap between API.md's documented "Stable surface"
and what was actually enforced: the
@public_apisignature-snapshot mechanism (tests/unit/test_public_api_contract.py) only coveredGeologicalModelmethods and 3utils/logging.pyfunctions, despite API.md also listingStratigraphicColumn,FaultTopology,StructuralFrame,FoldFrame, the 4 feature builders, the 4geometrydataclasses, andObservableas stable. Added@public_api(tier="stable")to the__init__of the six classes LoopStructural defines directly, and a newregister_external_stable(qualname, obj)helper in_api_registry.pyfor the five re-exported fromloop_common(BoundingBox/Surface/ValuePoints/VectorPoints/Observable) --loop_commonis a separately-releasable package and shouldn't import LoopStructural's registry, so registration happens at the re-export site (LoopStructural/geometry/__init__.py,LoopStructural/utils/observer.py) instead. Regeneratedtests/fixtures/api_surface_snapshot.json(32 -> 44 entries) to make the newly-captured signatures the accepted baseline. Addedtests/unit/test_stable_api_surface.pyfor what signature-snapshotting can't cover: module-path importability for the full "QGIS-plugin compatibility" list (previously only checked inline insideqgis-compat.yml's heredoc, CI-only), and member-name protection for the three Enums in the stable surface (FeatureType,FaultRelationshipType,StratigraphicColumnElementType) which have no call signature to snapshot. Simplifiedqgis-compat.ymlto call this new test file instead of duplicating the import list inline. Verified zero regressions by stashing all of this change and re-runningpytest tests/unit: identical 192 failed/9 errors on both sides (the pre-existing, documented-elsewhere failures), only new passing tests added on top. - 2026-07-30: Closed 2c-3 for real:
LoopStructural.geometry.BoundingBoxnow re-exportsloop_common.geometry.BoundingBox; the local_bounding_box.pyfork (global_origin/global_maximumreprojection) is deleted. Rather than adapting callers to loop_common's box in place, moved world<->local projection responsibility down into the interpolator/support layer:GeologicalInterpolatorgained abounding_boxattribute (set byInterpolatorFactory.create_interpolator) and now projects constraint points/vectors onset_*_constraintsand projects/reprojects onevaluate_value/evaluate_gradient(split into public world-facing methods delegating to new_evaluate_value_local/_evaluate_gradient_localabstract methods);loop_common'sSupportFactory.create_support_from_bboxnow builds the mesh in the box's local frame by default (fixing a pre-existing gap where it read.origin/.step_vectorraw, ignoring the local/world distinction entirely).GeologicalModelno longer pre-shifts data into a zeroed local frame at ingestion (prepare_datakeeps world coordinates);origin/maximumare now always world coordinates, with the near-zero interpolation frame set viaset_local_transform(local_origin=...).scale()/rescale()stay as public, signature-stable pass-throughs tobounding_box.project/.reproject. This surfaced and fixed several latent frame-mismatch bugs exposed byGeologicalFeature.evaluate_value/evaluate_gradientbecoming genuinely world-facing (previously local-only, withGeologicalModeldoing the only world<->local conversion):evaluate_value_misfit/evaluate_gradient_misfit,set_interpolation_geometry(shared by_fault_builder.py/_structural_frame_builder.py),LoopInterpolator.fit_and_evaluate_*,GeologicalModel.evaluate_model/evaluate_model_gradient/evaluate_fault_displacements/evaluate_feature_value/evaluate_feature_gradient(dropped their now-redundant manualscale()pre-conversion),GeologicalModel.regular_grid()(now returns world coordinates), and_base_geological_feature.py'ssurfaces/scalar_field/gradient_norm_scalar_field/vector_field. Also found and fixed an unrelated pre-existing break:bounding_box.structured_grid()returnsloop_common.supports.StructuredGrid(an interpolation support object with no properties dict), not the LoopStructural geometryStructuredGriddataclassscalar_field/get_block_modelactually need -- those call sites now constructLoopStructural.geometry.StructuredGriddirectly. Fixed a 2D-bounding-box regression in the new local-frame support-building code:BoundingBox.cornersis 3D-only, socreate_support_from_bboxandstructured_grid(local_coordinates=True)now projectorigin/maximumdirectly instead (exact for the translation-only transforms in use today). UpdatedCOMPAT.mdwith theBoundingBoxconstructor signature break (global_origin/global_maximumremoved) and regenerated the affected entries intests/fixtures/api_surface_snapshot.json. Rewrote the tests that depended on the oldglobal_origin/global_maximumAPI (tests/unit/geometry/test_bounding_box.py,tests/unit/modelling/test__bounding_box.py,tests/unit/modelling/test_geological_model.py,tests/integration/test_interpolator.py,tests/unit/interpolator/test_api.py). Verified:packages/loop_common/tests151/151,packages/loop_interpolation/tests396/396 (25 skipped),tests/integration20/20,tests/unit670 passed/2 failed/2 skipped (excluding one pre-existing collection error intests/unit/interpolator/test_2d_p1_p2_support.pyfrom the deadLoopStructural/interpolators/supports/code, per the note two entries up) -- both remaining failures (tests/unit/io/test_geoh5.py) are unrelated geoh5py data-type issues, confirmed pre-existing against the unmodified baseline viagit stash. This is a large improvement on the 192-failed/458-passed baseline noted above, since most of those failures were exactly thisBoundingBox.global_originattribute gap.