-
2026-09-17 --
HandoffManifest.VERSION: the hand-off sidecar has ONE document version, andformatis the instance discriminator it always really was. The FBX route wrote1and the USD route2although nothing about the document differed except that only the USD route carriesinstances, so the number named the CARRIER's dialect and a genuine schema change had no number left to turn; the instance replay gated onversion == 2ANDformat, of which only the second discriminates anything (a Maya producer spells a group's members as DAG paths, a Blender one as names, and replaying one as the other matches nothing). Every producer now writesVERSION, and the gates key onformatalone. Sidecars already on disk are re-MADE rather than refused, for free and without a cache bump: each bridge's_cache_keyalready carries its conversion template's own file identity, precisely so a template fix invalidates stale payloads, and the version literal lives in the templates. 6 writers, 3 readers, inventoried by grep. Measured gotcha for a reader:"version": 1also appears SECTION-locally (the rig graph, the shots section) -- those are different documents and are untouched. -
2026-09-17 --
Ktx2Encoder(uastc_rdo_dictionary=...)/args_for(uastc_rdo_dictionary=):--uastc_rdo_dbecomes a dial, and the default does not move. The GLB texture pass is bound by total UASTC+RDO encode work, not thread contention (the thread split was benched and refuted 2026-09-15), and the RDO match window is the dominant cost: measured on one 4K normal map alone on 20 cores at UASTC quality 2 / RDO 0.75, toktx's own default took 56.6 s for 18.22 MB,102424.9 s for 18.47 MB,25615.4 s for 18.60 MB, RDO off 12.1 s for 18.67 MB. The encoder could not express the option at all, so the trade could not be measured on real content -- which is the only thing that should move the default, because that probe was an UPSCALED SYNTHETIC map and RDO's size win on real normal maps is probably larger than it shows, making the penalty for a cheaper dictionary probably larger too. Same discipline the flat-keys decision took: a 56% win worth pursuing, a risk class (a bigger deliverable shipping to a headset over a network) not worth taking on reasoning alone. Emitted only inside the RDO branch -- toktx rejects the flag when RDO is off.test_ktx2_encoder33, +1. -
2026-09-17 --
RigMachinery(core_utils/engines/rig_graph/rig_machinery.py): the rule for what a baked rig leaves behind, with no DCC in it. Deciding which nodes are a rig's inert apparatus is three questions and only the first is about a DCC: what IS each node (a per-node fact), what does that make apparatus (an algebra over the hierarchy), and of what was delivered what may safely go (the same algebra from the other end, against a carrier that kept names and lost paths). The first belongs to whoever can see the scene; the other two are here, asclassify/unambiguous/select/tallyover plain values. Protection propagates UP and never down, so content keeps every ancestor holding it; apparatus means CONNECTED TO A RIG (seeded by the graph's own nodes plus constraint/IK nodes, swept to unprotected descendants and wrapper groups) rather than "draws nothing", which would take a scene's own export marker with the controls; and an unclassifiable kind counts as content, so an unrecognised shape survives. Every entry point takes the PRODUCER'sseparator("|"from Maya,"/"from Blender's prim paths):selectfirst hardcoded Maya's, which would have taken the whole path for the leaf on the return leg and silently stripped nothing -- caught by reading the other direction's extractor rather than by a test, and pinned by one now. Written because the first cut lived inside two AST-identical copies in blendertk's conversion templates, where it could only be tested by slicing the function out of the file and running it against a stub, and where the return leg could not reach it at all.test_rig_graph110, +20 -- and they run in 0.14 s with no DCC. -
2026-09-17 --
HandoffManifest.MACHINERY(core_utils/handoff_manifest.py). A new section in the hand-off vocabulary:{node: kind}, the rig apparatus a bake leaves inert -- constraint nodes, IK handles, control curves, up-vector locators, the groups holding only those, and joints nothing content is skinned to. Without it a baked rig arrives twice over, its motion in keys AND the whole apparatus that used to produce that motion, selectable and drawn and driving nothing (924 of 2727 transforms on the production module). Named by the producer and dropped by the consumer, never deleted at the source: one route bakes before it writes and the other samples the live scene, so removing a constraint there would remove the motion it was about to record.test_handoff_manifest46. -
2026-09-17 -- the hierarchy diff baseline is per SCENE, and its set algebra lives here (
core_utils/hierarchy_baseline.py,core_utils/export_profile.py). Both exporters kept the baseline in the export's.scene_data.jsonsidecar, keyed by the deliverable's file stem, which made it a property of the NAME rather than of the scene: renaming the Output Filename -- a prefix, a regex, a literal name -- pointed the next export at a different sidecar and silently started over, so the FIRST export after any rename passed no matter what had changed. Measured: one scene under six output names produced four independent baselines. The_v<N>stripping patched exactly one renaming axis, andnaming_reportasked the user to end names in_v<N>to keep the key working -- that warning is gone, along with the coupling it propped up (version_suffixis accepted and ignored for one release).HierarchyBaselineis the DCC-agnostic rule both exporters now run: a flat path set plus a hash, with scope derived at COMPARE time rather than stored, so nothing has to be keyed or migrated when an export is renamed or re-scoped.comparediffs only the part of the baseline in the current export's scope, so exporting asset B never reports asset A as missing;mergereplaces only that part, so ONE record accumulates every scope a scene exports. Three cases decided the scope rule, each pinned by a test: a scope with nothing recorded is NEW rather than wholly "extra" (a first export has nothing to diff, exactly as a missing manifest had nothing to diff); a root that MOVED is still in play, because wrapping an export in a group rewrites every path and a roots-only test would report a wrapped scene as a clean first export -- the exact accident the check exists to catch, and it matches on the root's own name, not its leaves, sincebody/Meshcollide constantly and would drag an unrelated asset into scope; and an EMPTY export puts the whole baseline in play, because "no scope" must not read as "nothing to check". Storage and path-normalisation stay with each DCC, which is where they legitimately differ (Maya'sexportSelectedships a node's parent chain, Blender'suse_selectiondoes not). Three defects the self-critique found in the first cut, each now pinned:mergeERASED the whole baseline for an empty export --relevant_rootsrightly puts everything in play when nothing is exporting (the diff should say so) but the merge then replaced all of it with nothing, so the next export saw a clean slate and the collapse that had just happened became invisible; it is a no-op now.is_recordseparates "no paths recorded" from "nothing readable here", whichdecodealone cannot -- both return an empty set, and a consumer usingdecodereported an intact empty record as a LOST baseline. Andtop_levelordered by depth alone over a SET, which is not a total order: the diff report rolls up to these roots and shows the first 20, so an unchanged scene could name different nodes run to run; it is (depth, path) now. Published surface +2 (HierarchyBaseline, andSceneDataSidecar.get_top_levelin both DCCs now delegates toHierarchyBaseline.top_levelrather than keeping a third copy of the same loop; snapshot updated). Newtest_hierarchy_baseline.py, 22 tests. -
2026-09-17 -- the hand-off sidecar has a type, and replaying it is a declared plan (
core_utils/handoff_manifest.py,core_utils/manifest_plan.py). Every conversion ships<payload>.manifest.jsonbeside its FBX/USD intermediate, but nothing owned that contract: the suffix was spelled at ten call sites, mayatk and blendertk each had their own tolerant reader plus seven more appliers that re-opened the same file to read one key, and the section names were bare literals in roughly eighty places across four producer templates, two bridges and two consumers -- a producer writing"visibilty"would have been caught by nothing.HandoffManifestis that contract: the suffix and its idempotentpath_for, the section vocabulary as constants (MATERIALS,SHOTS,RIG, ... andSECTIONS), a tolerantreadoverFileUtils.read_json,buildthat drops onlyNone(an empty list is a producer saying "I looked"), and an atomicwriteoverFileUtils.write_json-- a conversion that dies mid-write can no longer leave a truncated sidecar that the consumer's tolerant read would silently treat as "no sections". It is aMapping, so it drops straight into the appliers that took a plain dict. A section's CONTENTS stay with the codec that owns them (ShotTransferforshots, the rig engine forrig); adding a section is a constant plus a codec, never a change here.ManifestPlanreplaces each consumer's hand-sequenced chain:add(section, label, apply, when=, best_effort=)admits a step only when its gates pass, so the plan's length is what the progress bar should count, andrunapplies one progress/cancel protocol. A step raises unless it is declaredbest_effort-- the failure that must never be silent (a guaranteed instance replay leaving the scene flattened) is exactly the one an author would forget to mark -- andOperationCancelledderives fromBaseException, so a stop request can never be swallowed by a fidelity step's error policy. -
2026-09-17 --
RigTransfer: one applier for both bridges, and a destructive edit is now stated (core_utils/engines/rig_graph/rig_transfer.py). Building a rig on the far side is four steps in a fixed order -- build, verify, let the survivors take their channels, report -- and only the BUILDER is DCC-specific. Both importers had grown their own copy anyway, 76% identical in_apply_rig_sectionand 83% in the capability digest, which is precisely the drift this engine exists to prevent: two importers quietly disagreeing about what 'transferred' means.RigTransfer.applyowns the sequence and a DCC passes a builder (build/commit/remove/sample_world/scope/linear_unit/up_axis);RigTransfer.capability_keyreplaces the duplicated sha1-of-sorted-json. The one thing that is NOT shared is the source-unit fallback: a graph that omits its own unit is assumed to be the PRODUCER's convention, and the two directions disagree (Maya samples centimetres Y-up, Blender metres Z-up), so collapsing them into one constant scales every sample by 100 and swaps two axes for one leg -- which diverges every record and reads as "the rig would not transfer". It is a parameter each side states, a graph that omits the key is warned about rather than absorbed, and three tests pin it. Commit is LAST and that is load-bearing: it DELETES the payload's baked keys on the channels a verified record now drives, so committing before the measurement would leave a failed record with no motion at all -- pinned by a test that asserts the call order. The same pass makes destruction visible: the number of key curves deleted and everything a builder reports in its result'seditsare logged, where beforecommitreturned a count that nobody read. -
2026-09-17 -- a rig component is all-or-nothing, and the verify tolerance is a physical centimetre (
core_utils/engines/rig_graph/rig_model.py,rig_plan.py,rig_verify.py). The first production rig transfer built and verified 119 of 217 records in Blender and every one of them drove a bake: each wire loom's spline IK and auto-bend math had baked underneath the constraints that did build, so the result was constraints and helpers over baked children -- worse than the bake.RigGraph.components()groups the records that touch a common node (RigRecord.node_ids()); the planner's component rule bakes a component whole when any record in it bakes, drops or cycles (baked/component, the entry naming its blocker and the blocker's rule; a disabled record neither blocks nor binds), andRigVerify.verify_plan(..., graph=)takes a diverged or failed record's whole component back after the measurement (cascaded, through the DCC's ownremove). A consumer now ends with a complete, verified rig or exactly the bake, and logs it through ONE vocabulary (RigVerify.summary/RigVerify.demoted,DEMOTED_KINDS/PLANNED_KINDS) instead of a tally duplicated per DCC. Second: the defaultverify.tolerancewas1.0in whatever unit the source used -- a centimetre from Maya, a METRE from Blender (a return leg "passed" 86 records at 1 m; re-measured at 1 cm, 33 of them were 2.4-2.6 m off). It is nowVERIFY_TOLERANCE_M(0.01 m) expressed in the graph's unit, attached by the planner and defaulted the same way by the verifier. Alsotest/test_rig_graph.py's__main__guard sat before its last two classes, so a direct run skipped 13 verifier tests; moved to the end. -
2026-09-17 -- a token carries its own regex, and the wildcard stops corrupting one (
str_utils/_str_utils.py,core_utils/export_profile.py). The scene exporters shaped an export name with a regex typed into a SEPARATE field, parsed by a helper duplicated verbatim in mayatk and blendertk (already drifting -- blendertk's copy had lost two comments). A name rule split across two fields cannot be recalled as one: the panels' recent-filenames history records the name and not the regex, so picking a name off the list paired it with whatever regex happened to be in the option box. The modifier moves INTO the token grammar --{name:PATTERN->REPLACEMENT}-- landing in the one formatter every{token}in this class resolves through (_SafeFormatter, now module scope rather than rebuilt per call), soreplace_placeholders,resolve_placeholdersandresolve_name_patterngain it at once and with them every consumer: both reference managers,map_factory, uitk's tooltip preview. A spec is a modifier only when it carries a delimiter, so{n:03d}is still padding and the two grammars cannot collide -- both compose in one pattern ({name:_bar.*->}_v{n:03d}).|is deliberately NOT a delimiter: it is regex alternation, and the retired field's split-on-pipe made(foo|bar)->bazunwritable. A pattern that will not compile is returned, not raised -- the text comes from a user mid-edit, where half a regex is a keystroke -- and surfaces asregex_errorson both resolver dicts.expand_wildcardwas rewriting every*, including one inside a token:{name:_bar.*->}became{name:_bar.{name}->}, silently breaking every regex using.*,\d*or[a-z]*. It now expands only in LITERAL runs -- the wildcard is literal-text sugar, and a*inside braces belongs to that token's own grammar -- re-doubling escaped braces so{{lit}}_*still survives.ExportProfile.fold_legacy_regexfolds the retired field's two extra spellings (A|B->A->B, a barePATTERN->PATTERN->) into the shared grammar, so the saved field keeps working through ONE implementation rather than a second copy -- andfold_legacy_namingnow folds it INTO the pattern (StrUtils.attach_modifier, new) rather than onto the context value, so retiring the field cannot silently drop the rule and the user can see what it does. It reaches every spelling of the name token, retired ones included: the field shaped the NAME, not one way of writing it, and a saved{name}_xmust keep the RegEx it has always had.NAME_KEYnames the ONE token a blank field and*stand for, and it isscene-- a second token meaning "the scene name with the RegEx already applied" existed only while the RegEx lived in its own field.NAME_KEY_ALIASESkeeps the old spelling resolvable for a release. Published surface +3 (StrUtils.split_regex_modifier,apply_regex_modifier,attach_modifier; snapshot updated).test_str+11,test_export_profile+9, the wildcard regression red first. -
2026-09-17 -- the shot transfer carries the sequencer's content, not only its topology: keyed custom channels and audio (
core_utils/engines/shots/shot_transfer.py, schema v2). A pull landed the shots and none of what they contained: neither FBX nor USD animates a custom attribute, so the keyed opacity fade, the highlight pulse and its colour arrived as nothing, and neither carrier holds a sound, so the audio clips stayed behind. The section gains two payloads with the same contract as the store:channels--{object: {label: {value, keys}}}with a key as[time, value, interpolation]in the one vocabulary a far side can reproduce (KEY_INTERPOLATIONS: a hold, a straight line, the rest), labels in mayatk's attribute spelling so a colour travels as its three leaves -- andaudio, the clips as{name, file, start, end, offset}. What is DCC-specific stays injected:encode(channels=, audio=)takes what each side read (itsRenderEffects.channel_records, its clips) and only spells, scopes and retimes;decode(write_channels=, write_audio=)lands them BEFORE the ledger is read, because a claim on a channel the transfer itself creates must find its key. A section with content but no shots now exists andmergeleaves the scene's own store alone for it. This is the general mechanism the user asked for: a new effect is a new row in the DCCs' channel tables and travels with no transport change, and so does any keyed user attribute that is not in any table.test_shot_transfer+5 (25), red first. -
2026-09-17 -- the shot store crosses the Maya <-> Blender hand-off as a manifest section (
core_utils/engines/shots/shot_transfer.py). Neither FBX nor USD has a place for a shot list, a marker, a locked gap or the ledger of samples the sequencer planted on shot bounds, so a scene sent either way arrived with its animation and none of its shots.ShotTransferis the ONE codec both DCC adapters run:encodeturns a store'sto_dict()into the sidecar'sshotssection with every scene name respelled the way the carrier writes it and the ledger regrouped by object + channel label (mayatk'stranslateXvocabulary, which blendertk's sequencer already speaks), scoped to the exported set when one is given -- memberships and claims follow what ships, the shots themselves always cross;decodereads it back against the receiving scene through injected callables (name resolution, curve lookup, key existence), rescales to the receiving clock asrescale_to_fpswould, shifts by the importer's frame offset, and drops a claim whose key is not there -- the reducer may have taken it, and a claim without its key is debris;mergefolds the result into the scene's own store: a shot-less scene adopts it whole, one with shots gains the incoming ones after its own under fresh ids, every id reference (a locked gap's pair, a claim's owner) remapped. The section is the store's own dict shape, so a future store field travels without a codec change. One rule came from the live pulls rather than the design: both importers put ROOT objects through the Y-up / Z-up crossing and leave a child's parent-space channels alone, so a root'stranslateZclaim names the far side's Y channel --UP_AXIS_SWAP/swap_up_axisanddecode(converted=...)carry it, the consumers pass "has no parent". Published surface +1 (ShotTransfer, snapshot updated).test_shot_transfernew, 20 tests, red first. -
2026-09-17 -- the rig engine gains its verifier and the hand-off its rig-mode vocabulary (
core_utils/engines/rig_graph/rig_verify.py,core_utils/app_handoff.py). Phase 1 of RigGraph shipped the schema, capability model and planner; this is the first of the pieces that connect it to a scene.RigVerifyis section 9.4 made concrete -- ONE point-cloud comparison that mayatk's exporter, blendertk's importer and the tests all measure through, so the number adivergedreport entry carries is the same number everywhere. It splitsworstintorigid(the length of the mean delta: the whole object displaced as one, a placement or parent-space error) andresidual(the worst distance from that mean: the shape itself deformed differently, a skinning or solver error), because the production round trip's return leg read 0.0126 mm rigid against 0.0605 mm residual and only the split says which thing to fix. A point-count mismatch RAISES rather than measuring: a topology failure reported as a large distance would pass a generous tolerance.compare_frameskeeps the per-frame worst so a report can say WHEN it diverged, andverdictreturns both numbers beside the boolean, since a warning without its number is not actionable. Pure math over point arrays -- a DCC contributes only a sampler.RIG_MODE_PARAM/RIG_MODESsit beside the carrier vocabulary for the same reason it lives here: one spelling for every panel (uitk) and every producer (the DCC mixins).("auto", "bake", "rig", "raw"), ordered because the first is what an undecided request gets, mirroringHandoffBridge.carriers.rigextracts a graph, plans it against the CONSUMER's capability and bakes only what the plan says, so it can never carry less thanbake; that is the carrier decision of 2026-09-17 (FBX and USD both stay) made into one parameter instead of a per-carrier flag.test_rig_graph+6,test_bridge+2, red first. Later the same day, the verify-and-demote LOOP moved here too (RigVerify.verify_plan,convert_point): blendertk's importer had its own copy, and its sampler could not see BONES, so every bone-targeted record -- half the production module's -- compared zero points and passed vacuously. Now a DCC contributes onlysample(node_id, frame) -> point | None; the unit and up-axis conversion (a Y-up cm source's(x, y, z)is a Z-up metre target's(x, -z, y)/ 100), the comparison, the demotion (remove(record_id)) and itsdivergedentry live once, and a record that had samples but nothing measurable is reportedunverifiedand left built -- the check did not happen, and calling that a pass is the one thing worse than a miss.test_rig_graph+3, red first. -
2026-09-16 --
Deprecation: one mechanism for retiring public surface, and the first one that records WHICH release an alias stops working in (core_utils/deprecation.py,core_utils/symbol_record.py,core_utils/help_mixin.py,core_utils/git.py,file_utils/_file_utils.py,net_utils/preview_server.py,core_utils/engines/textures/map_factory/conversions.py). The public-API contract (CODE_STANDARD.mds5) gives a renamed or removed name an alias for ONE release. The first half of that rule was honoured seven different ways across the ecosystem -- an inlinewarnings.warnwith a hand-pickedstacklevel, a per-cluster warn helper with a different one, a module__getattr__that warned and another that did not, acmds.warning, a logger call, a silent binding alias, a docstring line for a renamed keyword -- and the second half not at all: "removed in the next release" names no release, so nothing could fail when the release came and went. Measured:UvUtils.flip_uvswas deprecated 2025-12-17 and has shipped in 50 mayatk releases since; theFileManageraliases in 34 uitk releases. So the point is not that seven shapes collapse into one, it isremove_in: every deprecation names its removal version, an unparseable one raises at import (a version that cannot be compared is an alias that never expires), and the records are readable at runtime (Deprecation.registered/expired/report) AND statically. Four shapes, one per thing the ecosystem actually retires:symbol(function, method or class -- on a class it wraps__new__, so a SUBCLASS warns too, which the hand-written__init__warn it replaces could not see),parameter(one keyword of a function that stays, including the rename-and-remap case, which retires theuse_object_axes-style docstring line nothing enforced),attributes(moved module attributes, chaining onto any__getattr__already installed rather than clobbering a lazy loader), andvalues(a retired member of a value vocabulary), plusDeprecation.warnfrom a function body for a shape none of the four reach -- the only sanctioned alternative, so that an odd case cannot justify a hand-rolled notice. A property is handled in either decorator order. Two rules are enforced rather than documented: a replacement is MANDATORY (ValueErrorotherwise -- a notice the caller cannot act on is noise), and warning is never breaking. Attribution is computed, not guessed: the machinery counts its own frames, so a warning points at the caller's line through any of the four shapes and through the lazy package resolver -- the six hand-rolled call sites carried two different hardcodedstacklevels between them, each worked out by hand.__deprecated__is set to the message string, which is exactly what PEP 702'swarnings.deprecateddoes on 3.13+, so the eventual migration is a deletion; it is stamped on the WRAPPED function as well as the wrapper, becausefunctools.wrapssets__wrapped__andHelpMixinunwraps to recover a signature -- marking only the wrapper would have reported every deprecated member as live.Deprecation.sinkis the DCC escape hatch (DeprecationWarningis hidden outside__main__, which is every DCC session): additive, once per record, and a raising sink cannot take the call down with it. Two dead consumers now have a producer:HelpMixin._is_deprecatedhas read a__deprecated__marker nothing ever set, and the registry generator has recognised a decorator nothing ever defined.SymbolRecordgainsremove_in(the one field added since the set was frozen; the sidecar omits it where empty, so the committed JSON grows only where a retirement was recorded). Converted pythontk's own four hand-rolled sites --Git, theFileUtilsJSON key-value cluster, thepreview_serveralias module,ConversionRegistry.register_from_class-- all of which said "next release" and now say 0.11.0. Newtest_deprecation.py, 65 tests + 30 subtests, including the live gate over pythontk's own surface (every module imported viapkgutil, 0.3 s, becauseexport_all()resolvesDEFAULT_INCLUDEand a pure alias module is named by nothing). Downstream conversion is release-gated: pythontk 0.10.0 is on PyPI and every consumer floors at>=0.10.0, so uitk/mayatk/blendertk adopt after this publishes. -
2026-09-16 -- new
core_utils/engines/rig_graph/: a DCC-agnostic model of a rig's INTENT, what one target can build, and the pure planner that resolves the two. A rig converter written assource node type -> target constructis N x M and never converges, which is why in-house ones get abandoned. This engine carries what a rigger MEANT instead, on the observation that every rig relationship has the same shape: a target receives a value computed by an operator from some sources.rig_modelis the document -- FIVE record shapes (transform/channel/points/order/opaque), strictly validated, with theopinside a shape left OPEN and resolved against a target rather than this schema, so the vocabulary grows without touching the validator or the planner (an unknown op is a planner outcome, never a malformed document -- pinned by a test). Identity is the payload's prim path, so a record joins to a carrier prim with no name matching, no namespace rewriting and no collision-suffix tolerance; a node's parent is its path's parent, so the hierarchy has no second spelling to keep consistent. Any parameter value may be{"plug": "<id>.<channel>"}instead of a literal, which is how a driven constraint weight or a driven twist is said without a new record kind; a sanitised prim name cannot contain., so the FIRST dot ends the path and the grammar needs no escaping.channel/exprcarries a restricted grammar (arithmetic, comparison, and 14 named functions includingselect(c, a, b)-- a FUNCTION, not control flow, which is what lets a condition node translate) validated by an AST walk that refuses attribute access, subscripting, comprehensions and lambdas outright, so the escape a nulled-__builtins__evaluator still allows does not exist here. Deliberately NOT unified withMathUtils.eval_expression: that is a calculator -- no variables, the wholemathmodule, a formatted string back -- and the two share only the idea of walking an AST, so bending either into the other would cost both their contracts.rig_capabilityis what ONE target can build, as data (fidelity, channels, roles, params with enum values, and the parameter paths a plug may drive), answering "could you build this record?" without planning a graph.rig_planis a PURE function of the two: it returns what to build, what to bake, what must be verified, and a report entry for every loss -- emitted by the code that causes the loss, so it cannot drift from what happened the way a hand-maintained caveat list does. Two rules carry it: degrade, never break (each record owns its fallback, and "drop and bake" is not a second code path but this planner against a target with no builders registered), and a half-built node is worse than a baked one (a node stays procedural only if EVERY record targeting it was built) -- which cascades, since demoting a record bakes its other targets too, so it is resolved to a fixpoint rather than in one sweep. Cycles are found once per graph with an ITERATIVE Tarjan (a 3000-deep chain must not exhaust the stack -- tested) and baked as a component: a cycle the rigger meant still plays, it is only frozen. Because the planner touches no scene, a consumer can run a PRE-FLIGHT -- "12 rigs will be baked; 3 deformers have no equivalent" -- before committing to a ten-minute conversion, with nothing open and nothing written. Root exports:RigGraph/RigNode/RigRecord/RigPolicy/RigCapability/RigOpCapability/RigPlanner/PlanResult/ReportEntry/RigPlanRefused. Specification:.claude/RIG_GRAPH_SCHEMA.md. A policy field is UNSET until stated, not filled with a default at parse time -- otherwise a record that says nothing overrides the graph with a default it never asked for, and the graph-level default applies to nothing (caught in review, with a repro: a graph declaringfallback: dropresolved tobake). Measured end to end against a production module before anything downstream was built, which paid for itself twice: a first-cut extractor emitted 231 records for 539 driver nodes and the planner reported a clean bill of health for the 43% it had read, soRigGraph.coverage()now makes "emitopaquerather than nothing" a CHECK rather than a plea -- an extractor records what it saw insource.census, every record records what it was read from inprovenance, and the difference is returned by type (the real run: 308 unaccounted,multMatrix196 at the top). That same census refuted the vocabulary:multMatrix196 +blendMatrix14 is the LARGEST driver population in the scene and the scalarexprgrammar cannot express a matrix product at all, so the spec gains atransform/matrixop. Also corrected in review: a policy field is UNSET until stated, not filled with a default at parse time -- otherwise a record that says nothing overrides the graph with a default it never asked for, and the graph-level default applies to nothing (repro: a graph declaringfallback: dropresolved tobake). Provenance is counted as DISTINCT source nodes, not occurrences -- two records legitimately read the same constraint, and tallying both let one node stand in for another and report unaccounted=0 while a real driver went unread, which is the exact false negative the check exists to catch (caught in review, with a repro). Provenance naming a type the census never mentioned is surfaced separately asuncensused: an extractor contradicting itself is a reason to trust neither number. Newtest_rig_graph.py, 58 checks.test/surface_snapshot.jsonrefreshed for the new exports -- it was ALREADY stale onProgressRelayfrom the 2026-09-15 work, so the suite had a pre-existing failure. -
2026-09-15 -- a blocking headless run can report progress and be stopped while it works (
core_utils/app_launcher.py,core_utils/script_run.py).AppLauncher.run(on_output=...)streams the child's merged stdout+stderr line by line on the caller's thread (composing the existingOutputStream/ProcessReader), calling back with each line as it arrives and withNonewheneverpoll_intervalpasses quietly, so a caller driving a UI keeps it alive through a long silent step. ReturningFalse-- or a cancelled ambientCancelScope-- kills the child and raisesOperationCancelled;ScriptRunner.run_script_to_artifactpasses the hook through and, on a stop, removes its rendered script and any partial artifact (nothing to debug in a run the caller ended). NewProgressRelaymaps a child's printed::progress:: k/n textmarkers AND in-process steps onto oneprogress(current, total, message)callback -- the shapeuitk'sprogress_adaptergives a footer bar -- with each stage owning a slice of the bar, keep-alive ticks throttled, and the value never moving backwards. Why: a production Maya->Blender conversion is silent for minutes behind a fixed 600 s budget; on 2026-09-15 that budget killed a conversion that was still working, and the panel had nothing to show while it ran. The marker is one plain line so a dependency-free DCC template can print it.test_script_run.py+13 (delivery while the child runs, keep-alive ticks, kill-on-False, ambient scope, timeout under streaming,output_fileexclusivity, and the relay's mapping / throttle / monotonic bar).