plan/upstream-issues.md: upstream SMLib problems only
15 KiB
AGENTS.md — solidcore
solidcore is a port of NVIDIA's SMLib B-rep kernel (C++, Apache-2.0) to idiomatic safe
Rust. Upstream is cloned (gitignored) at upstream/, pinned to commit c9c5979. Many
agents work here at once: read this whole file before your first change.
crates/solidcore the kernel (pure Rust, no unsafe, wasm-ok) owner: unit porters
crates/solidcore-ffi extern "C" bindings to the C++ kernel (feature cpp)
crates/difftest side-by-side Rust vs C++ runner
harness/cpp C++ reference build (build.sh -> bin/cpp) + shim/<unit>.cpp
plan/ modules.toml (units, claims), STATUS.md, findings.md, order.md
1. Hard rules
Licence. Read only upstream/source/SMLib, upstream/source/SM_API,
upstream/tests/SM_API_test, upstream/tests/prog_test, upstream/TestFiles/pt_TestFiles,
upstream/AGENTS.md, upstream/.agents/ (skip any file about OCCT or USD). Never open,
read or build OCCT_BREP_*, SM_API_USD, BREP_SM_USD, BREP_USD_DATA, HYDRA_USD_BREP,
USD_BREP_UTILS, schema, SmPyLib, SmPyDevLib or proposals/. Never read GPL/LGPL code
anywhere (including dependencies; deny.toml bans copyleft crates).
Library code (crates/solidcore).
- No
unsafe(#![forbid(unsafe_code)]). Onlysolidcore-ffimay use it. - No
unwrap/expect/panic!/todo!/unchecked[i]indexing (denied by lints; tests are exempt). Use.get(), iterators,?andSolidError. - Deterministic: no clock, no hashing of addresses, no
HashMapiteration order in results (useVec,BTreeMap, or sort), randomness only fromkernel.rng. - No global or
static mut/thread_localstate. Everything lives inKernel. - Must build for
wasm32-unknown-unknown: no threads, filesystem orstd::time. - Never allocate from an input-declared size unchecked (file headers, counts in data):
use checked arithmetic and call
DatabaseReader::ensure_available(count, min_bytes, op)before allocating, asBSplineSurface::read_from_dbdoes. Upstreammallocfails softly; Rust aborts. Never pre-reserve (Vec::with_capacity,vec![x; n]) from a count read out of a file: an in-memory item is often far larger than its minimum on-disk size. Grow the collection as items are actually read. - Polymorphic readers that can nest (
Curve::read_from_db, and the surface/topology dispatchers when ported) calldb.enter_nested(op)?before dispatching anddb.leave_nested()after, so crafted files cannot recurse without bound. - Recursion. wasm32 has no stack guard page: a stack overflow there silently
corrupts memory instead of aborting. Any recursion whose depth depends on input
(subdivision, refinement, tree/topology walks, nested geometry) carries an explicit
depth parameter or counter and returns
SolidErrorpast a documented bound that is well above anything upstream fixtures reach. Converting to a loop with an explicit stack is allowed only if it visits in exactly the same order. Note each bound in the file header's Deviations line.audit.recursion(last unit) sweeps the whole crate.
Correctness gate. A unit is green only when difftest runs the Rust and the C++
(via FFI) on the same inputs and topology is identical and every value agrees within
1e-9 relative (|a-b| <= 1e-9 * max(|a|,|b|); an absolute floor only where a unit
documents one per key in abs_floors(), recorded in its report). Never loosen a tolerance to get green. A disagreement you cannot fix
goes in plan/findings.md (unit, inputs, both values, suspected cause) and the unit
stays blocked.
Git. Work happens through pull requests to github.com/bherbruck/solidcore. Never
push to main directly, never force-push shared branches, never rewrite main's
history, never reset/checkout/restore files you did not write. Stage explicit
paths only (never git add -A/git add .). End every commit message with your
tool's attribution, e.g. for Claude Code:
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Processes. Kill only processes you started, by PID. Never pkill/pgrep -f.
2. Machine budget
8 cores / 16 threads shared by every agent.
export CARGO_BUILD_JOBS=2 RUSTC_WRAPPER=sccache # sccache is installed in ~/.cargo/bin
nice -n 10 cargo test -p solidcore # every cargo/C++ command under nice
- C++ builds:
nice -n 10 harness/cpp/build.sh --jobs 6. Only rebuild ifbin/cpp/libis missing or the harness changed; one shared build serves all worktrees (below). - Run
build.shfrom the main tree; worktrees reuse that build by exportingSOLIDCORE_CPP_DIR=$HOME/github/solidcore/bin/cpp(the CMake build dir is keyed by source tree, so a worktree run gets its own build dir but installs to the samelib/). - Before claiming a unit, check load:
cut -d' ' -f1 /proc/loadavg. If > 24, do not claim; finish or wait. - After merging, delete your worktree's
bin/(each worktree has its own cargo target). - Unset
RUSTC_WRAPPERif sccache is missing; the repo never requires it.
3. Code style
Port faithfully, write idiomatically. Keep upstream numerics: same formulas, same
operation order, same iteration order, same branch structure for tolerance tests and
degenerate cases. Do not "simplify" a*b + c into mul_add, reorder sums, swap in
hypot/midpoint, or replace loops with library calls that change rounding. The C++
reference is built with -ffp-contract=off, so matching order gives matching bits.
Everything else is Rust-first:
- Data model. Topology lives in
topology::Breparenas addressed by typed ids (FaceId,EdgeId,VertexId,LoopId,ShellId,BrepId); no pointers,Rc, orRefCellgraphs. Geometry is concreteenums (Curve,Surface) over structs, not trait objects. Points/vectors aremath::Vec3(Point3is an alias, as upstream). - Kernel. Tolerances, RNG and caches are fields of
kernel::Kernel(upstreamSmContext). Take&Kernel(or&mut Kernelwhen using rng/caches); it must staySend. - APIs. Return values, not out-params (tuples or small structs for multiple
outputs). Fallible fns return
solidcore::Result<T>; buildSolidErrorwith theoperationpath (e.g."nurbs::knots::insert") and entity id. Many optional arguments become an options struct withDefault. UpstreamSmStatuscodes with no matching variant map toSolidError::Upstream { code, .. }. - Names. Drop
NL_/Sm/SM_/smgu_/Hungarian prefixes; snake_case fns, CamelCase types (SmBSplineCurve->BSplineCurve,NL_KnotInsert->knot_insert). - Organisation. A unit's
rust_module(nurbs::knots) lives atcrates/solidcore/src/nurbs/knots.rs, declared withpub mod knots;innurbs.rs(nomod.rs). Keepmodlines sorted, one per line, so parallel merges conflict cleanly. Unit tests go in the same file (#[cfg(test)] mod tests). - Docs. Every public item has a doc comment (
missing_docswarns; CI uses-D warnings). Lints are in rootCargo.toml[workspace.lints]; do not add#[allow]for denied lints in library code.
File header
Every ported file starts with:
//! One-line summary of what the module does.
//!
//! Upstream: `source/SMLib/src/NL_KnotVec.cpp`, `source/SMLib/inc/NL_KnotVec.h`.
//!
//! | Rust | Upstream |
//! |-------------------------|----------------------------------|
//! | [`KnotVector`] | `NL_KNOTVECTOR` |
//! | [`KnotVector::insert`] | `NL_KnotInsert` |
//! | `find_span` (private) | `nl_FindSpan` |
//!
//! Deviations: <anything not 1:1, e.g. out-param -> return value, rand() -> kernel.rng>.
4. Porting a unit (PR workflow)
Unit list and states: plan/modules.toml (format: plan/modules.schema.md, order:
plan/order.md). plan/PROGRESS.md is the checklist (regenerated on main by CI;
never edit it in a PR). Any todo unit can be claimed. A unit is ready when its depends_on are all
green or partial (soft_depends_on never blocks; see section 4a); ready units are
the easiest, lowest layer first. Claiming a unit that is not ready is fine:
- Calls into a missing dependency are stubbed (
UNPORTED(<unit>), cases deferred; 4a). - If you need a type (struct, enum variant, base class) from a missing dependency,
port the minimum of it yourself and record it in that unit's
notesinplan/modules.tomlasCO-PORTED in <your unit> (PR #n): <C++ symbols> -> <Rust path>so its eventual porter extends your code instead of duplicating it. - If that dependency has an open
port:PR, do not port the same code twice: build on that PR's branch (merge it into yours) or agree on the split in that PR's comments.
One-time setup (per machine): clone this repo, clone upstream at the pinned commit
into upstream/ (see README), build the C++ reference with
nice -n 10 harness/cpp/build.sh --jobs 6.
- Claim = a draft PR. Check nobody has it:
gh pr list --state open --search "<unit id> in:title". If none, branch from the latestmain(git fetch origin && git switch -c unit/<unit id> origin/main),git commit --allow-empty -m "claim: <unit id>", push, and open a draft PR titled exactlyport: <unit id>. The lowest-numbered open PR for a unit wins; if yours lost, close it. Do not editplan/modules.tomlto claim. Push WIP at least daily; a draft PR with no pushes for 48 h may be taken over (comment on it first). If the unit'snotesmention a WIP branch onorigin, start from it. - Port the upstream functions the unit owns (listed in
harness/callgraph/units.json) into itsrust_module, plus the C++ shim (harness/cpp/shim/<unit_id_with_underscores>.cpp, functionssc_<unit>_<fn>, plain C data only), itssolidcore-ffibinding, and its difftest runner and fixtures, followingcrates/difftest/README.md. Inputs: the unit's upstreamtests, plus fixed-seed generated and edge cases (degenerate, boundary, tolerance limits). - Wire back-edges (section 4a): replace
UNPORTED(<unit id>)stubs elsewhere with real calls and re-run the difftest of every unit you touched. - Verify (
export CARGO_BUILD_JOBS=2 RUSTC_WRAPPER=sccache, everything undernice -n 10):cargo fmt --all --check cargo clippy --workspace --all-targets -- -D warnings cargo test -p solidcore cargo check -p solidcore --target wasm32-unknown-unknown cargo run --profile difftest -p difftest --features cpp -- --unit <unit id> # and every unit you touched cargo run --profile difftest -p difftest --features cpp -- --green # what CI runs (~2 min) - Before you mark a PR ready, check each item. Earlier PRs passed CI yet still
disagreed with the C++ because of these:
- Default arguments match upstream (e.g.
IsDegenerate()defaults toSM_EFF_ZERO, not a zone tolerance). - No parameter dropped or hard-coded (e.g.
0.0passed instead of the caller's value). - Every upstream special-case branch ported (e.g. cylinder reparametrisation for a line parallel to the axis).
- Statuses upstream relies on are honoured (e.g.
Unitize()turning a zero vector into(0,0,1)). - The shim builds every geometry kind the code branches on (real
SmEllipse/SmCircle/SmLine/..., not B-spline stand-ins), or those branches are never compared. - FFI guards reject exactly the inputs that are undefined or crash in C++: no more (valid inputs stay covered), no fewer (no garbage or segfaults).
- Generated cases cover degenerate, boundary, tolerance-limit, huge/tiny-scale and sub-domain inputs, and most cases compare real values, not just an error status.
- No panics on extreme caller input: bound derivative orders and counts; file readers bound header counts; input-driven recursion is bounded (section 1).
UNPORTEDstubs only for calls into units that are notgreen/partial, tagged with the owning unit fromharness/callgraph/units.json; co-ports are recorded asCO-PORTEDinplan/modules.tomlnotes.
- Default arguments match upstream (e.g.
- Finish the PR: merge the latest
origin/maininto your branch (never rebase a pushed branch), re-verify, set the unit'sstatusto"green"or"partial"inplan/modules.tomlwith case/deferred counts innotes(plus any units you promoted), commit, push, and mark the PR ready. Put the difftest summary lines in the PR description. CI runs fmt, clippy, tests, wasm and difftest on every green or partial unit; a maintainer merges when CI is green.
Blocked (gate cannot be met, upstream behaviour unclear): record details in
plan/findings.md, set status = "blocked" with a reason in notes, and open the PR
with just that so others can see it. Never sit on a draft PR you have stopped working
on: close it, and say where the WIP is.
4a. Cycles, back-edges and the merge lock
The kernel core is one big call cycle, so the plan dropped ~2,300 calls as back-edges
(listed in each unit's notes: cycle back-edges (...): <unit>(<calls>)). Rules:
- Soft dependencies.
soft_depends_onlists dependencies a unit calls at most twice and needs no type from (generated byharness/plan/soften.pyfrom the call graph). They do not block claiming: treat every call into a soft dependency that is notgreen/partialyet exactly like a back-edge below. Onlydepends_onblocks. - Calling unported code. A call into a unit that is not
green/partialyet goes throughErr(SolidError::Unported { upstream: "<C++ symbol>", unit: "<unit id>" }). Never panic, never fake a result. Every such site carries the comment// UNPORTED(<unit id>): <C++ symbol>so the target's porter can find it with grep. - Difftest with back-edges. Cases whose C++ path reaches an unported callee are
classified
deferred(the Rust side returnsUnported); they are neither pass nor fail. A unit with all non-deferred cases passing and >=1 deferred case ispartial. With zero deferred cases it isgreen.partialsatisfiesdepends_onfor scheduling only; it is never reported as green. - Wiring back-edges when you port a unit. After your unit passes, grep
UNPORTED(<your unit id>)acrosscrates/solidcore, replace each stub with the real call, and re-run difftest for every unit you touched. Any unit whose deferred count drops to 0 and passes becomesgreen; record this in the same commit. - Local swarms only: when several agents share one clone, everything that writes
that clone's
mainruns underflock .git/swarm.lock -c '<commands>'.
5. Where things go
plan/findings.md: Rust-vs-C++ disagreements, upstream bugs, nondeterminism (e.g. upstreamsrand(time(NULL))), anything a later agent must know.plan/upstream-issues.md: upstream C++ defects also get an entry here (problems only, with location, trigger, how found and whether the C++ build confirmed it).plan/PROGRESS.md: the checklist (generated; never edit by hand).plan/STATUS.md: overall progress summary and handoff notes.bin/: all build output (cargotarget-dir,bin/cpp/for C++). Gitignored. Never commit build output or anything underupstream/.