A decidability substrate — immutable, lazily-evaluated resolver graphs where partiality is first-class. Geometry is instance #1.
fungeom is a decidability substrate — a Python library for building computation as a lazy,
immutable graph you can reason about before you compute it. You compose typed values, ask whether
the result can be resolved, and — when it can't — get back a reason, not an exception or a
silent NaN.
Geometry is instance #1 and the most developed: you build points, vectors, frames, transforms, time-signals, and regions, and every question about them is decidable. Time is instance #2 — the same machinery, one dimension down — and the two compose (a signal is a value that moves). The substrate isn't geometry-specific; geometry is just the first thing modeled in it, and "anything honestly decidable" is the goal.
Its one big idea: partiality is first-class. A question with no answer (a point in a frame that
was never placed, a direction from a zero-length vector, a marker occluded mid-capture) is an honest
Unresolvable with an explanation that propagates through everything built on top of it — never a
crash, never an invented number. And that partiality is the bottom of an uncertainty lattice: binary
(Resolvable / Unresolvable) today, but designed to grade toward resolvable-within-ε and
beyond — a refinement of fungeom's own partiality, not a bolt-on. What belongs in the substrate is
therefore selected on honesty and referential transparency, not on the kind of math
(what belongs here).
from fungeom import Point3, Frame, Resolvable, Unresolvable
gripper = Frame.detached("gripper") # a sub-assembly, not yet placed in the world
tip = Point3.at(0, 0, 0.1, frame=gripper) # a point in the gripper's frame — built lazily
match tip.decide(): # ask whether it can be resolved...
case Resolvable(point): print(point.coord)
case Unresolvable(why): print(why) # "frame 'gripper' is not grounded to the world"Nothing above is computed until you ask. decide() returns evidence — the value if it resolves,
or the reason if it doesn't — and resolve() is just decide().unwrap(), so the two can never
disagree.
- Decidable, not crash-prone. Every resolver answers
decide()→Resolvable(value)orUnresolvable(reason). Partiality propagates: a midpoint is resolvable only if both ends are, and the reason flows across type boundaries unchanged. - Lazy & immutable. Geometry is a graph of frozen values; every op returns a new node and
computes nothing until
decide()/resolve(). You can even render the graph to see where an unresolvability lives. - Everything is a resolver — even scalars. A scale factor, an interpolation parameter, a
vector's norm are all first-class nodes, so values flow across types and dividing by a
resolves-to-zero scalar is
Unresolvable, not a runtime error. - One class per primitive. You both construct from it (classmethods like
Vec3.of,Point3.at) and compose with it (fluent methods likea.midpoint(b)). No builders, no visitors.
pip install fungeomRequires Python 3.13+; the runtime deps (numpy, scipy, rich, shapely) come with it.
For development — from a checkout, with the dev extras (ruff, mypy, pytest):
git clone https://github.com/ryanrudes/fungeom && cd fungeom
uv pip install -e '.[dev]'| Layer | Primitives | What it's for |
|---|---|---|
| Geometry | Scalar, Vec2/3, Direction3, Transform, Frame, Point3, Plane, Line, Ray, Segment (+ 2D siblings) |
points, frames, rigid motion — the classic kit, made decidable |
| Logic | Bool |
three-valued predicates with strict propagation |
| Time | Duration, Instant, Interval, Coverage, Timeline, Sampling, TimeMap, TimeWarp |
durations, clocks, gappy supports, alignment |
| Signals | ScalarSignal, Vec3Signal, …, PlaneSignal, BoolSignal, FaceSignal |
values that vary over time — partial functions of a clock (incl. a moving patch) |
| Collections | …Bundle, …BundleSignal, Roster, RosterMap |
keyed sets (marker clouds) and sets-over-time, occlusion-aware |
| Regions | Region2, Face, Point2Bundle |
bounded planar areas, the balance margin, bounded contact patches |
The complete combinator table (every constructor, every op, and its exact partiality) lives in
docs/reference.md.
Everything composes and stays lazy. Here is the spine of a real motion-capture task — when is a foot in contact with the ground? — built without ever inventing a number:
clearances = ground_cloud.fit_plane().signed_distance(foot_cloud) # per-marker height, over time
contact = clearances.min().le(0.0) # a three-valued BoolSignal
print(contact.when_true().resolve()) # the contact interval(s)
print(contact.first_true().resolve()) # touchdown
print(contact.last_true().resolve()) # releaseIf a marker drops out, the predicate is Unresolvable there — undefined, never silently False.
See examples/10_contact_over_time.py for the runnable version.
Runnable, commented scripts in examples/ (each is exercised by the test suite, so
they stay current). Start at the top and work down:
| Script | Shows |
|---|---|
01_quickstart |
construct → compose → resolve; scalars flowing across types |
02_coordinate_frames |
a kinematic chain; grounding, why an unplaced frame is Unresolvable, and reading a point's coordinates back in a frame |
03_decidability_and_partiality |
value-dependent partialities, reasons, propagation; predicates as decidable Bools |
04_visualizing_resolvers |
rendering the lazy graph to see where an unresolvability lives |
05_time_and_clocks |
the temporal layer: durations/instants, intervals & coverage with gaps |
06_signals_over_time |
signals as partial functions of time; at/resample/reparameterize; slerp on a manifold |
07_aligning_and_warping |
recovering the time map between two recordings from landmarks |
08_point_clouds_over_time |
a Point3Bundle and a cloud over time — an occluded marker is honestly Unresolvable |
09_regions_and_patches |
the 2D region algebra, the balance margin, and a bounded-patch Face |
10_contact_over_time |
the contact spine end-to-end; touchdown & release from marker data |
11_free_variables |
the unknown as a first-class leaf — author a patch as data over free markers, then bind/resolve_in their positions |
python examples/01_quickstart.py- Wiki — narrative guides: the core concepts, each layer, and how to add a primitive.
docs/reference.md— the complete combinator table, architecture, and design notes.- What belongs here —
docs/substrate-membership.md: fungeom as a general decidability substrate (geometry instance #1, time #2), and the honest-referential-transparency rule for what is admitted vs. parked. - Deep dives —
docs/time.md,docs/collections.md,docs/regions.md.
uv pip install -e '.[dev]'
pytest --cov=fungeom # tests + 100% coverage gate
ruff check . && ruff format --check .
mypy # strictCI runs all four on every push; the same checks are available as pre-commit hooks
(pre-commit install && pre-commit install --hook-type pre-push). Every primitive and combinator,
with the checks it has passed, is tracked in CHECKLIST.md.