Skip to content

add opt-in stream splitting that reaches MER on split-needed problems - #2

Merged
sarangbhagwat merged 24 commits into
masterfrom
dev
Oct 8, 2026
Merged

sarangbhagwat merged 24 commits into
masterfrom
dev

Conversation

@sarangbhagwat

Copy link
Copy Markdown
Member

What

Optional stream splitting for heat exchanger network synthesis, behind a new keyword that defaults to off:

HXN = HeatExchangerNetwork('HXN', T_min_app=5., stream_splitting=True)

The same keyword is on synthesize_network and plan_network. With it on, the synthesizer reaches the
minimum-energy-requirement (MER) targets on problems that provably need split streams. Before this change
it reported 'best_effort' on all of them.

Why

PR #1 made the pinch-outward planner reach MER on every problem an unsplit network can serve. The other half of the
MER corpus (SPLIT in tests/hxn_mer_cases.py: 25 constant-CP literature problems with up to 22 streams, and 13
real-thermodynamics water/ethanol/methanol problems with phase change and binary glides) needs splits by the pinch
design rules. A network without splits cannot reach MER on any of them; the hot-utility excess ran up to 35 % (cgm_unbalanced10).

How

  • Only where needed. A side of the pinch is split only when the unsplit planner cannot close it: a pinch-rule
    proof, or an unsplit search that leaves a utility penalty. Every other side gets the same network as before, so a
    problem that needs no split gets the identical network with the option on.
  • Stage S (textbook splits). Closed-form pinch splits fix the number rule, then the CP rule, using transport-based
    split ratios. Each branched side is then planned by the existing DFS, unchanged. This is the pick on most of the
    corpus' split sides.
  • Core (provable backstop). Fixed-fraction vertical blocks on the composite curves, coarsened and verified exactly
    at every knot. Optional L&H pinch blocks and DFS tails keep unit counts down. From any state that satisfies the
    planner's residual condition (R), a vertical block always exists and preserves (R). So whenever MER is reachable,
    the core reaches it exactly on the planner's knots, including flats (isothermal phase change), double pinches and
    zero CP slack. The derivation is in the hensmith/_splitting.py module docstring.
  • Exact heat closure. Core nodes are closed-form positions, so no round-off is carried between blocks. Candidates
    that would leak heat are rejected, not booked. Real-thermo split sides go through the existing exact-state refine
    loop, with candidate stickiness and a split-only retry.
  • Realization. Branch exchangers are ordinary HXprocess units with scaled inlets and enthalpy limits. Splits use
    bst.Splitter chains and bst.Mixer(rigorous=True). Both are adiabatic and cost nothing, and each mixer is created
    at its planned state. StreamLifeCycle gains additive branch bookkeeping (splits, entry, H_in,
    connections()). The facility wires units from those connections on both the fresh and the cached paths, and
    cache_network is keyed on the option.

Compatibility

  • Default off. With the option off, behaviour is bit-for-bit unchanged. An oracle checked this after every
    commit: a float.hex dump of all 78 corpus plans, 93 facility runs, and the cache, avoid_recycle, Qmin,
    replace-utilities and ideal-thermo variants, under two hash seeds.
  • Public API. synthesize_network still returns the same 13-tuple. Split structure goes to info['splits'] /
    synthesis_info['splits']. synthesize_network(stream_splitting=True, info=None) raises ValueError, because
    the splitters and mixers are returned only through info.
  • Unchanged. The version, targets, problem_table and default heuristics. biosteam needs no change.
  • Docs. Docstrings, docs pages (concepts, API, tutorials 02-04, contributing) and one README sentence describe
    the option.

Tests

957 passed, up from 467. Run locally with the CI invocation on Python 3.14: 362 s, against 248 s before.

  • Headline. All 38 SPLIT cases, with stream_splitting=True, through the public facility (76 items):
    • test_split_network_reaches_mer: status 'mer', and heating and cooling equal to hensmith's targets and to
      the independent references within 1e-12 × total duty (constant CP) or 1e-10 (real thermo).
    • test_split_network_balanced_and_feasible: a strict split-aware checker (G0-G10). It covers splitter and mixer
      mass and energy balances, isothermal re-joins, fractions, life-cycle structure, per-exchanger heat balance, and
      the exact internal approach in every exchanger (≥ T_min_app − 1e-6 K). 15 mutation tests show that each check
      catches its defect.
  • No-split identity. All 40 NO_SPLIT cases give the same network with the option on.
  • Regression. Cases 04, 08 and 09 reach 'mer' with splitting.
  • Other new tests. Backstop-alone MER, exact-dTmin enthalpy-limited branches, the cached split network, planner
    primitives and fuzz tests.
  • Random problems outside the corpus.
    • 52 problems: all reach 'mer' with every strict check clean.
    • 40 more: all reach 'mer'. In 2 of them the CP rule forces a branch below the harness's 1e-3 minimum fraction.
    • 30 problems with avoid_recycle: no crash and no infeasible exchanger. MER is not guaranteed under that option.

Known limits

  • luo_20sp_ph10c10 (below the pinch) needs the vertical backstop: 52 exchangers and 34 splits on that side.
  • With avoid_recycle=True, a split that would repeat a pair is not used, so MER is not guaranteed.

Pre-existing issues found (not changed here; they affect the default path)

  • _planner._Side._R loses precision on near-flat curve pieces (glides under about 1 µK). The split path uses an
    exact replacement.
  • With cache_network=True, a cached network is kept even when sys.converge() fails. Reproduced on
    bagajewicz_ou_crude_unit_15_stream with feeds scaled by 1.1 and then 0.9.

🤖 Generated with Claude Code

sarangbhagwat and others added 24 commits September 23, 2026 22:07
…ing, transport

First step (T1) of optional stream splitting in the MER planner: the new
private module hensmith/_splitting.py with the primitives the later
split planner builds on. Nothing calls it yet, so every existing result
is unchanged (R1 oracle below).

- _branch_curve (Lemma B): a branch of flow fraction f is the parent's
  level curve with every heat times f (same levels, slopes / f, flats
  kept).
- _Cell: one exchanger in parent coordinates (duty x; must branch of
  fraction f over [a, a + x/f], flex branch of fraction g over
  [b, b + x/g]; stage keys).
- _cell_margin (Lemma 1): the approach at tau = 0, tau = x and every
  knot of both curves inside their ranges, which decides feasibility
  exactly on piecewise-linear curves. A must knot is evaluated at its own
  position (the flex at the matching tau) and vice versa, so no knot
  level is perturbed by the change of coordinates. `touch` flags a cell
  that runs parallel at the minimum approach along a segment.
- _Coupling: the vertical (composite-quantile) coupling at a node, with
  closed-form must and flex positions from side.analyse's residual
  arrays. Breakpoints are merged, and positions snapped to the curve
  ends, at round-off only (_SPLIT_ULP * max(X, 1), 8 eps), never at tolQ:
  tolQ of heat can hide a knot tolQ/CP away in level, far more than tolP
  (the 'merged breakpoint' case: flex B starts 4e-7 K above the pinch,
  1.2e-6 < tolQ = 2e-6 of heat away; its breakpoint is now kept). The
  position formulas use a strict flat test (t < Di[k]) so both flat and
  sloped parts return the residual arrays exactly at their ends.
  must_at/flex_at evaluate the same closed form anywhere in [0, X] (for
  the pre-leaked root P(delta)).
- _transport: greedy (continuations first, then north-west by level),
  Edmonds-Karp max flow when the greedy is stuck, cycle cancelling to a
  forest (at most m + f - 1 cells), exact row sums (the largest cell of a
  row takes the row heat minus the others). The greedy needs no
  cancelling: every assignment exhausts its row or column, so its cells
  never close a cycle. Adjacency lists (not sets of string-tagged tuples,
  as in the prototype) keep the result independent of PYTHONHASHSEED.

Refinements over the design, each for robustness:
- float-empty folding folds an interval over which no *must* position
  changes (a superset of "no position changes"): such an interval
  carries no must heat, so a block over it would be empty; its flex
  movement is round-off, or the <= tolQ residual of a must too small for
  side.analyse's composite, and joins the next interval.
- touch uses |approach| <= tolP (identical for any feasible cell) and a
  segment longer than tolQ of duty (not merely > 0), so near-coincident
  knots of two curves cannot fake a parallel run.

Tests (tests/test_hxn_planner.py, written first and seen failing with
NotImplementedError): test_branch_curve_is_the_scaled_parent,
test_splitting_preserves_the_residual_cascade (Lemma R: the branched
root has the same levels and composites, and any branched node the same
slack), test_cell_margin_matches_scaled_max_duty (500 random cells with
flats: margin >= -tolP iff _max_duty on branch-scaled curves, and equal
to a direct evaluation), test_cell_margin_touch,
test_vertical_coupling_positions (SPLIT and merged-breakpoint roots plus
random (R) nodes: monotone, P(X) == Qm exactly, sum(P - a) == t and
sum(R - b) == t within 1e-12 scale, no breakpoint merged beyond
round-off, B's breakpoint kept), test_transport_forest (NW, continuations,
forbid, the stuck-greedy case, infeasibility, 300 random instances:
exact rows, forest, compatibility and forbid respected). A scratch probe
confirmed the tests exercise the max-flow fallback (65 times) and cycle
cancelling (8 cycles) and catch four mutations (tolQ merging, no forest,
no max-flow, end-points-only margin).

Validation:
- full suite (CI invocation): 473 passed in 205.50s (467 before + 6 new).
- ast.parse(feature_version=(3, 12)) on both changed files.
- R1 oracle, planner mode:
  R1 oracle (planner): 78 records, sha256 7307a56778e56268, 573.8 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
  R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…candidates

Second step (T2) of optional stream splitting in the MER planner: the
vertical core of the provable backstop in hensmith/_splitting.py. The
planner does not call it yet, so every existing result is unchanged
(R1 oracle below).

- _vertical_block (Theorem V', Corollary C). From a node that satisfies
  (R), the block between two consecutive coupling breakpoints, with must
  heats P(t_e) - P(t_s), flex heats R(t_e) - R(t_s) and any transport
  between them, is feasible, and the node after it satisfies (R). Under
  _SPLIT_COARSEN it tries the block to X first, then gallops (2, 4, 8
  intervals) and bisects. Feasibility is not monotone in t_e, so every
  coarsened block is verified cell by cell and needs every fraction
  >= _SPLIT_MIN_FRACTION. The elementary block needs no search. If one of
  its cells still fails (C), recovery tries, in order: insert a composite
  breakpoint hidden inside the interval; attach the failing must heat to
  the must's series cell in the previous block, if that re-verifies; leak
  it to the must's utility if it is at most _SPLIT_R_TOL tolQ (recorded
  per must); otherwise raise _SplitInvariantError. A block that neither
  advances a must nor books a leak raises too.
- Node discipline. A chain of vertical blocks runs on the breakpoints of
  one coupling: a vertical block hands its coupling to the next one.
  Every node is that coupling's closed-form positions, never the start
  plus the duties, so round-off never accumulates. The column round-off
  of a transport is balanced on the largest flex, and anything above
  max(_SPLIT_R_TOL tolQ, 4 (M+F) ulp) raises.
- _exact: the core analyses its nodes with no heat tolerance. The
  search's _Side.analyse omits a stream's last tolQ of residual heat. In
  the constructed case of test_core_node_check_is_exact, it reports a
  slack of -0.5 tolQ at a node whose exact slack is 0, so the (R) check
  (-_SPLIT_R_TOL tolQ) would raise spuriously there.
- _preleak_root and _preleak_max (Lemma P). _cascade's own tolerances
  can leave a root slack of up to -(_THRESHOLD_TOL/_REL_Q + 0.1) tolQ.
  The core starts from P(delta), delta = -slack, where (R) holds exactly.
- _drive runs strategy 'V' from the pre-leaked root. It checks (R) at
  every node (raising, never asserting), sets the stage keys
  ('B', block, stream, branch) and returns a _Candidate.
- _Candidate verifies every merged exchanger with _cell_margin and holds
  the key (small, mixbad, touch, units + extra branches + split stages,
  most mixers, order), the info meta and the signature. The signature is
  knot-independent, with canonical branch ordinals. _merge_cells merges
  series continuations.

Deviations from the design:
- Coupling reuse replaces the design's per-node coupling. Re-analysing at
  every node adds each node's frontier levels as breakpoints; these are
  not composite knots, and they can subdivide an interval geometrically.
- Couplings and node checks use _exact instead of side.analyse.
- The missed-knot rule inserts the first composite breakpoint value
  (De/Di/Se/Si) strictly inside the interval that has must heat on both
  sides of it. The design's rule looks for a curve knot strictly inside
  the interval, and that misses a flex's start (the merged-breakpoint
  case).
- For now _CORE_STRATEGIES = ('V',). _CORE_ORDER fixes the tie-break
  order V, LV, VT, LVT.
- Additive signatures: _drive(..., Qmin=0.), _merge_cells(cells, tolQ),
  and extra _Block slots (cp, k, span, leak, knots, work).

Tests (test_hxn_planner.py, section 16), written first:
- test_theorem_v_random: elementary V from the (pre-leaked) root on 367
  sides. They come from 200 constant-CP problems, 50 point-load problems,
  50 problems jittered by 3e-8..1e-6 K (a far 1e4-CP pair sets tolQ/CP
  above the jitter), the merged-breakpoint case and the two near-tie
  cases. Every side passes _verify_side_cells with (R) after every block,
  no leak and no inserted knot.
- test_vertical_core_on_split_sides (coarsened and elementary).
- test_vertical_core_from_the_preleaked_root: on the near-threshold and
  near-double-pinch cases, V fails from the root and is exact from
  P(delta).
- test_coarsened_blocks_end_at_vertical_nodes.
- test_core_node_check_is_exact.
- test_vertical_block_recovery: missed knot, leak, attach and raise.
- test_vertical_block_forbid_and_used.
- test_candidate_key_and_signature.

Scratch check (knots.pkl), on the synthesizer's round-0 knots, with
_SPLIT_COARSEN off: rtB07 above gives 276 blocks and 1064 units, every
node at exact slack 0 (and by direct evaluation), leak 0, knots 0, in
0.22 s. Coarsened, it gives 3 blocks and 12 units. On all 52 root-proof
sides of the 38 SPLIT cases, both modes pass verify_core, in 0.63 s
total coarsened and 1.58 s elementary. The coarsened unit counts match
the prototype's V column (for example luo below 49 with 34/80 and
nptel_t5_3 below 4 with 2/4).

Validation: full suite 482 passed in 283 s (T1: 473 passed in 206 s).
The new tests take about 7 s, so most of the difference is run-to-run
variance. py3.12 syntax checked with ast.parse(feature_version=(3, 12)).

R1 oracle (planner): 78 records, sha256 7307a56778e56268, 744.6 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ansport

Fixes for the T2 inspection findings. Each finding was checked against
the code before it was fixed.

1. (major) An elementary vertical block could fail silently.
   `_Vertical.cells` keeps only flexes with more than `ulp` of heat in
   the interval (design 2.4.2: a flex is absent only when g_j <= ulp).
   Take a float-nonempty sliver interval whose flex heat equals the must
   heat up to round-off but is shared by two or more flexes, each with at
   most one ulp. Every flex was dropped while a must still carried heat.
   The gap balancing then had no column (`if gap > 0. and g`),
   `_transport` found no pair for the row and returned None, and
   `elementary()`, `_vertical_block` and `_drive` passed that None up.
   None is also the documented result for "forbidden or used pairs only",
   so T3's `_split_side` would have dropped the strategy with no entry in
   `split['errors']`. That breaks Theorem V' (the elementary block is
   always accepted) and 2.4.3(6). Reproduced on the inspector's side
   (must 1 starting o x 20 ulp above must 0, two identical flexes):
   `_drive(side, [0, 0], 'V')` with coarsening off returned None for
   o = 1.2, 1.5 and 1.9 and succeeded for 2.5 and 3.5.

   Cause and fix: the ulp filter treats each flex's heat as round-off,
   but when a must carries heat the total is not round-off. When the
   filter leaves no column and some row has heat, the flex with the most
   heat is kept, and the existing balance (at most
   max(_SPLIT_R_TOL tolQ, 4 (M+F) ulp)) puts the rest on it. Its cell is
   verified like any other, and a failure would go through the
   missed-knot / round-off recovery. The None contract now holds by
   construction. When an elementary block's transport fails, `cells`
   re-runs it without `forbid`/`used`. It returns None only if that
   re-run succeeds, i.e. avoid_recycle removed pairs the block needed.
   Otherwise it raises `_SplitInvariantError`: by Theorem V' a transport
   on all pairs always exists, so the strategy fails and is recorded.

2. (minor) No test covered the minimum fraction of coarsened blocks
   (2.4.3(2)). New test: flex 1 carries 0.005 of heat parallel to flex 0
   up to level 10, so every block there sends the must to flex 1 with a
   fraction near 5e-4. The block to X verifies cell by cell but is
   rejected for that fraction. Elementary blocks cover the range and one
   coarsened block covers the rest. With _SPLIT_MIN_FRACTION = 0 the
   block to X is taken. Mutation check (scratch): with the module
   constant set to 0 the test fails; at 1e-3 it passes.

3. (minor) The test helper `_verify_side_cells` anchored every stage's
   branches at the stage's lowest start. `_remix` splits a must at its
   far end (max a_end), where the branches share the split state, and
   mixes it toward the pinch; a flex splits at its start. The helper now
   anchors must stages at the far end and flex stages at the start, and
   a must stage spans [far - D, far]. For V the remixes are isothermal
   and the two anchors coincide, so the existing checks are unchanged.
   The new test shows a valid non-isothermal must stage passing and a
   must stage anchored at its start failing. T4b/T5 can therefore reuse
   the helper for Stage S and pinch blocks.

4. (minor) `_Candidate` counted every non-isothermal must remix as
   mixbad. Design 2.5 counts only remixes "followed by a process
   exchanger of the same stream". A must mixes toward the pinch, so its
   remix now counts only if an exchanger of that must lies below the mix
   point (a_end <= pm + tolQ, outside the stage). A utility or the
   pre-leak does not count. This implements the design's rule now
   instead of deferring it: it mirrors the flex rule, and it changes
   nothing for V (see the scratch probe below).

Tests, written first:
- test_vertical_block_on_a_round_off_sliver (the inspector's side for
  o in 1.2, 1.5, 1.9, 2.5, 3.5, coarsening off, passes verify_core; a
  vertical block with no transport on all pairs raises)
- test_coarsened_block_keeps_the_minimum_fraction
- test_verify_side_cells_anchors_stages_like_remix
- test_candidate_key_and_signature: a non-isothermal must remix that
  feeds only its utility (0) and one followed by an exchanger (1)
Before the fixes, the sliver, helper and remix tests failed for the
reasons above. The min-fraction test passed (the code was right, the test
was missing) and fails under the mutation.

Scratch checks:
- With _SPLIT_ULP raised to 1e-11 relative (the inspector's probe) and
  coarsening off, 367/367 core_sides pass verify_core (before: 3
  returned None). At the real ulp, 367/367 pass as well.
- Rerunning T2's rtB07 / 52-split-side probe (knots.pkl) gives the same
  blocks, units, keys (mixbad included) and node slacks as T2's output;
  only the timings differ.

Validation: full suite 485 passed in 251.19s (0:04:11) (T2: 482 passed
in 283 s). A code comment was reworded after the suite started, so
test_hxn_planner.py and the _splitting doctests were run again on the
final files: 52 passed. py3.12 syntax checked with
ast.parse(feature_version=(3, 12)).

R1 oracle (planner): 78 records, sha256 7307a56778e56268, 596.9 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
T3 of the stream-splitting design: V-only split mode, end to end at the
planner level. With stream_splitting=False (the default) no new code runs
and no new info key appears; the R1 oracle below proves the planner is
bitwise unchanged on the whole corpus and its variants.

The switch (_planner.py)
- plan_network(..., stream_splitting=False, _split_exclude=None,
  _split_prefer=None), keyword-only. The private dicts (side -> set of
  network signatures, side -> candidate name) are for the refine loop.
- _plan_side(split=None) hook (design 2.1): a root proof 'outward' or
  'inward', or a 'cascade' root whose deficit is within _preleak_max (the
  cascade's own tolerances, Lemma P), goes to _splitting._split_side; so
  does an exhausted schedule whose best effort leaves a penalty above
  _SPLIT_R_TOL tolQ (1e-14 of the scale, below R2's 1e-12). A side the
  unsplit search serves returns exactly as before. If no candidate exists
  (deficit beyond _preleak_max, or avoid_recycle left no block), today's
  best effort applies.
- _plan_sides(split) passes it through; the avoid_recycle loop collects
  used pairs from the pieces and the cells (empty when off).
- _SidePlan(cells, split, units), Exchanger.__init__ with hot_frac,
  cold_frac = 1 and hot_branch, cold_branch = None, Plan.splits and
  Plan.paths (always set; paths == stages when unsplit).
- Record dispatch: the existing record/walk/safety-net block runs
  verbatim unless some side plan has cells.
- side_info[s]['split'] only when stream_splitting; _plan_numeric's match
  dicts gain hot_frac, cold_frac, hot_branch, cold_branch only when
  plan.splits.

Selection and records (_splitting.py)
- _split_side (core only for now): V from the pre-leaked root
  (_preleak_root, an exact analysis). The preferred candidate is tried
  first and taken as is when live; otherwise every generator runs (the
  preferred one not again) and the smallest _Candidate.key wins among the
  candidates whose signature is not excluded; a side's last candidate is
  never excluded. A generator raising _SplitInvariantError is recorded in
  split['errors']. Gaps are a0 + leak per must, so the side's penalty is
  truthful (MF4). split info: candidate, signature, candidates ({name:
  key tuple | 'excluded' | 'error' | 'forbidden pairs'}), stages,
  branches, preleak, leak, small, errors.
- Split (stream, side, key, fractions, branches, H_split, H_mix,
  isothermal), _record, _paths, _walk_paths, _approach_violation_split and
  _split_records (design 4.3): trunk records and split stages occupy
  disjoint parent ranges per stream and side, so one sort by midpoint
  orders them pinch outward (a must stage spans [far end - D, far end],
  a flex stage [start, start + D], which also orders non-isothermal Stage
  S remixes correctly); musts flow toward the pinch, flexes away; branch
  records walk by Q/f (parent-equivalent enthalpies) and the mix is written
  with the duties, H_mix = H_split -/+ sum Q, so fractions add no
  round-off. Split sides are never Qmin-dropped (MF5). The safety net uses
  the fractions; a record it drops (a bug) keeps its branch, empty, so the
  fractions still sum to 1 (the realization can bypass it).

Tests (written first; they failed on the missing keyword)
- check_split_network: walks plan.paths with Q/(f CP) per branch; ends of
  every exchanger, fractions, H_split/H_mix, flat order and seqs,
  utilities, targets, and MF4 per must of every split side (served + gap
  == Qm within 1e-12 of the scale).
- test_split_cases_reach_mer (V), test_split_plan_heat_closes_exactly,
  test_splitting_off_adds_nothing, test_threshold_and_near_tie_roots_preleak
  (preleak == -root slack: 23.7 and 0.05 tolQ; a 'cascade' root beyond
  _preleak_max keeps today's best effort), test_split_exclusion_by_signature
  (core), test_fuzz_splitting_always_reaches_mer, plus
  test_split_records_walk_a_non_isothermal_remix (hand-built non-isothermal
  must stage, and the drop path), test_split_side_candidates_and_the_hook,
  test_exhausted_schedule_splits (the proof=None trigger).

Deviations from the design, with reasons
- The exhausted-schedule hook passes be.work, not work + be.work:
  _best_effort already adds the work passed to it, so the design's sum
  counts the search twice (the test checks it).
- check_split_network asserts penalty <= preleak + 1e-12 scale only on
  problems without sub-tolQ temperature offsets. On the jittered fuzz
  problems the unsplit planner's own tolerances reach the utilities
  (_sides omits a stream part of at most tolQ at the pinch; _cascade
  thresholds a trivial side's target): 5 of 100 jittered seeds show
  0.15-0.4 tolQ of penalty, 3 of them with no split at all. MF4 is
  asserted instead, per must, on every split side, which is the property
  the bound was meant to prove.
- Fuzz: one third random_problem, one third pinch_problem rejection-sampled
  on needs_split (random_problem alone needs a split about once in 100),
  one third jittered: 119 of 300 plans split, 170 need none and are
  identical to the flag off. _SPLIT_S_WORK and the rules-disabled half
  come with Stage S (T4b).
- test_split_exclusion_by_signature uses a test-only second core
  generator (the elementary V chain, via monkeypatched _CORE_STRATEGIES
  and _drive), since V is the only core strategy until T5.
- test_splitting_off_adds_nothing skips the two slowest fixtures (7.5 s
  of best effort); the oracle proves R1 on the whole corpus.
- _walk_paths takes tolQ (for isothermal).

Scratch probes (docs/superpowers/plans/stream-splitting/scratch/t3_*.py):
avoid_recycle with splitting on the SPLIT dict + 40 split-needing pinch
problems never repeats a pair (V under cap1 mostly finds no block: 34 of
43 fall back to best effort, as designed until Stage S); Qmin between
the exchanger duties keeps every split cell and reports it in 'small'.

Validation
- Full suite: 496 passed in 297.74s (0:04:57) (T2: 485 in 251 s; +11
  tests, the fuzz 3.3 s and the off test 2.7 s of it).
- ast.parse(feature_version=(3, 12)) on the three changed files.
- R1 oracle, planner mode (hxn_synthesis.py and
  _heat_exchanger_network.py untouched):
  R1 oracle (planner): 78 records, sha256 7307a56778e56268, 730.6 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
  R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
T4a of the stream-splitting design (2.3.1-2.3.3): the pinch-split
machinery that Stage S (T4b) plans with the unchanged DFS. Nothing calls
it yet, so the planner is unchanged with the flag on or off; the R1
oracle below confirms it.

Cut sets (_cut_sets)
- Repeats rules_violation's selection at a tight cut on the side's local
  indices. Outward: demands = the musts leaving L, capacities = the
  flexes at L, CP = 1/slope_right; inward: demands = the flexes reaching
  L from below, capacities = the musts, CP = 1/slope_left. A flat
  capacity is left out (split-invariant, unlimited series capacity: a cut
  with one violates nothing); a flat demand returns None (it accepts only
  a flat partner, so no split serves it). rules_violation's own dict is
  untouched.

Transports (_cut_transport, _SPLIT_RULES in order)
- partner / demand: a best-fit maximum matching (demands CP descending,
  each taking the smallest free capacity that fits; optimal since the
  neighbourhoods are nested), then the unmatched demands join the
  capacity with the most room (partner, demands whole) or take the
  largest free capacities in proportion (demand, capacities whole).
- mincell (Linnhoff-Hindmarsh minimum cells, from probe_pinchfirst's
  structure/pack): super-bins of merged capacities with sum(|S|-1) = k,
  k = 0..3, exact branch-and-bound packing of the demands (CP descending,
  capacity pruning, identical-bin symmetry breaking, bound on the bins
  still reachable and the current smallest ratio), score (bins used,
  smallest CP ratio); the best packing at the smallest feasible k, each
  group served by the uniform-rho north-west corner; otherwise one group.
- nw-rho-desc/asc and nw-exact-desc/asc: the north-west corner against
  the capacities scaled to c/rho (every cell has branch-CP ratio rho) or
  unscaled (capacities never reached stay whole). Each cell is the
  overlap of two intervals on the prefix sums, so no subtraction chain
  carries round-off; breakpoints within _SPLIT_ULP of the total coincide.
- Compatibility is rules_violation's, c >= m (1 - 1e-12) (_CP_TOL), so
  the transports agree with the screen that follows.
- _cut_fractions: f = load over the demand's load sum (= load/m), g =
  load over the capacity's load sum (the whole capacity splits, no
  bypass, so g c >= load); fractions sum to 1 within round-off.

Branched side (_pinch_split, _split_items, _branched_side)
- Items (role, parent, fraction, key), musts first; key ('S', parent, n)
  on a branch, None on a trunk. A branch below _SPLIT_MIN_FRACTION of its
  parent merges into its largest sibling (MF9); a parent left with one
  branch is a trunk again.
- Screen at the branched pre-leaked root (must branch at f a0_i): slack of
  (R) within 10 tolQ of the side's (Lemma R, else _SplitInvariantError),
  then rules_violation there. A cut still violated gets the same rule
  again (branches as items; a branch of a branch has the product
  fraction), at most _SPLIT_CUTS = 3 cuts; a split that changes nothing,
  or a violation left after three cuts, fails the rule (None).
- extra = sum(branches - 1) over the split parents.

Corpus behaviour (scratch t4a_probe2.out): all 32 constant-CP SPLIT sides
with a rules proof have a succeeding rule, in one cut except nptel_t5_3
below: the double pinch there makes the rho rules and mincell ping-pong
between the two tight cuts (they fail, as design section 3 notes);
nw-exact-desc succeeds at once (+2) and nw-exact-asc after three cuts
(+8). smith2005_ex18_4 below (zero CP slack, 300 on 200 + 100) needs one
cut for every rule but partner.

Tests (written first; they failed on the missing _SPLIT_RULES,
_cut_sets, _cut_transport, _pinch_split)
- test_cut_transport_rules (per rule): 2h1c -> +1 (partner, the cold
  stream hosts both; demand fails), 1h2c CP rule -> +1 (partner fails),
  zero slack (smith2005_ex18_4 below) -> branch CP == partner CP, cornell
  above -> 3 cells (partner and demand fail; mincell == the k = 1 one-
  group structure); 300 random cut sets on scales 1e-3..1e3, half tight:
  every demand served, no capacity overloaded, g c >= f m per cell,
  uniform rho for the rho rules, a forest, the rule's shape, and no split
  at all where a whole matching exists (partner, demand, mincell).
- test_mincell_prefers_fewer_cells (k = 0 whole packing; fallback).
- test_pinch_split_screens_the_branched_root (min-fraction merge undoes a
  1e-4 branch; flat demand; a broken Lemma R raises).
- test_pinch_split_on_the_corpus (every constant-CP SPLIT side; the
  branched curves and root checked independently; the double pinch and
  its pre-leaked variant NEAR_DOUBLE_PINCH need the extra cuts).
- Mutation probe (scratch t4a_mutation.py): no-tolerance fits, NW never/
  always inflating, partner/demand swapped, mincell as one group, wrong
  capacity fractions, no min-fraction merge, one cut, unscaled root: each
  fails at least one of the new tests.

Deviations from the design, with reasons
- _cut_sets(side, a, b, L, cut, rule) takes no items: it runs on the
  branched side itself, whose local indices are the items.
- _pinch_split(side, a0, proof, rule) takes the pre-leaked root a0 (the
  screen needs it) instead of items (it always starts unsplit; the items
  are its output). _branched_side(side, items, a0) rebuilds the side and
  its root for T4b.
- _MINCELL_WORK counts the nodes and merge-set families of one mincell
  transport together, which bounds its time also when many families
  exist; the probe's "every merged set used" filter is dropped as
  provably redundant (a packing leaving one empty is feasible at a
  smaller k, which was searched in full).
- The zero-slack case of test_cut_transport_rules is the corpus's
  smith2005_ex18_4 below (design 2.8(c)), where partner, not demand,
  fails.

Validation
- Full suite: 506 passed in 495.69s (0:08:15) (T3: 496 in 298 s; +10
  tests, 10 s of them in isolation). The machine ran slower this session:
  the planner oracle, which runs none of the new code, took 1046 s
  against 730 s at T3.
- ast.parse(feature_version=(3, 12)) on both changed files.
- R1 oracle, planner mode (hxn_synthesis.py and
  _heat_exchanger_network.py untouched):
  R1 oracle (planner): 78 records, sha256 7307a56778e56268, 1046.1 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
  R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
T4b of the stream-splitting design (2.3.4, 2.5, 2.6, 3): a side that the
pinch rules prove needs splits now gets Stage S candidates, one per rule
of _SPLIT_RULES, besides the vertical core V, and the smallest selection
key wins. Stage S branches the side at the violating cut (T4a's
_pinch_split) and plans the branched side with the planner's own DFS,
unchanged, so it usually adds one splitter/mixer pair where V adds many.
With the flag off nothing new runs; the R1 oracle below confirms it bit
for bit.

Stage S (_stage_s, _s_search, _s_cells, _s_candidate, _fold)
- Per rule, in order: _pinch_split -> _branched_side (pre-leaked root
  a0' = f a0_i per must branch), then the first _SPLIT_PASSES = 3
  _SCHEDULE passes from a0' through _Search's existing a0, deduplicated
  as _plan_side does, with avoid_recycle's forbidden pairs mapped onto
  every branch pair of their parents. A rule whose branching equals an
  earlier rule's is skipped ('same split').
- Unit bound: once a live incumbent has small == mixbad == touch == 0,
  the DFS runs with unit_bound = score - extra - stages (score = its
  units + extra branches + split stages), which keeps ties; a rule that
  cannot beat it ends as 'bound'.
- One work budget for all rules of a side, _SPLIT_S_WORK = 30000 x
  work_scale (the size of the _BE_WORK best effort that S replaces),
  deterministic; a rule not reached, or stopped by it, is 'budget'.
- Conversion: _merge(pieces), then _Cell(parent(i'), parent(j'), x,
  a'/f, b'/g, f, g, key(i'), key(j')) (Lemma B). Folding (Lemma F): a
  flex branch the DFS left unused gives its fraction to its used
  siblings in proportion (g/G), keys are renumbered, and a single used
  branch is a trunk again; positions b'/g move toward the pinch, where
  the flex levels are no higher, so every cell stays feasible. Branches
  that are all used keep their fractions exactly.
- Residual sweep (MF4): the DFS completes a must within tolQ. For each
  must item, r = f (Qm_i - a0_i) - its duties; |r| <= tolQ goes to the
  item's far-end cell, and the later cells of that cell's flex item move
  by r with it, so the flex stays an exact prefix; the moved cells are
  re-verified with _cell_margin. Otherwise the plan is rejected
  ('sweep'): Stage S books no leak.
- Verification: every merged cell by _Candidate (a failure is an
  invariant error, recorded in split['errors']); with cap1 a plan whose
  merged cells repeat a pair is rejected ('repeated pair').
- Signature and exclusion as for the core (_excluded); an excluded
  candidate stays in the list. The winner (smallest key among the live
  S candidates) gets _improve_units and _units_guard on its branched
  side from a0'; the result replaces it if it is live and its key is
  not worse.
- _SPLIT_FIRST_WINS (False; a runtime lever): _stage_s stops at its
  first live candidate and the core is skipped when one exists.

Portfolio (_split_side)
- s_ok: the root proof is 'outward' or 'inward', and the root deficit is
  within _preleak_max as for the core.
- Stickiness: a preferred 'S:<rule>' runs alone first (_stage_s(only=),
  winner improvement included) and is taken if live; otherwise the
  portfolio runs Stage S (skipping the preferred rule), then the core.
- avoid_recycle: every candidate, the core's too, is rejected if its
  merged cells repeat a pair (_repeats_a_pair, design 5.6).
- split['candidates'] names every rule run: its key or why there is
  none ('no split', 'same split', 'budget', 'bound', 'no plan', 'sweep',
  'repeated pair', 'error', 'excluded').

Planner (_planner.py): _improve_units and _units_guard gain a0=None and
pass it to _Search; at the default the searches are today's (R1 below).

Corpus check (scratch planner_corpus_check.py on the 38 SPLIT cases'
round-0 knots, knots.pkl; planner_corpus_check_analytic.out for the 25
constant-CP cases on their analytic knots)
- All 38 'mer'; every split side's cells pass _verify_side_cells from
  the pre-leaked root; mixbad == 0 everywhere; no errors.
- 52 split sides; 50 pick a Stage S rule (partner 29, demand 10,
  nw-exact-desc 5, mincell 4, nw-rho-desc 1, nw-rho-asc 1), V 2.
  Most sides add one branch (one split stage).
- Bound per case (units + extra branches + split stages, whole
  network), for the tests of T9b/T10 (per side in scratch
  t4b_sides.txt):
  cornell 11, nptel_t4_4 7, nptel_t5_3 9, smith2005_ex18_2 6,
  smith2005_ex18_4 6, smith2005_ex16_5 5, smith2005_exr18_4 8,
  smith2005_exr18_7 8, 7sp3 11, 7sp4 11, 8sp1_dt20F 10, 8sp1_sargent 9,
  nptel_t5_7 11, smith2005_exr18_5 9, crude_fractionation 25,
  bagajewicz 69, bjork_pettersson 27, fs_15sp_tkm 17, cgm_balanced8 23,
  cgm_balanced10 33, cgm_unbalanced10 32, luo_20sp 132, fs_22sp1 28,
  fs_22sp_ph 23, grossmann_balanced12 41, rtB01 14, rtB02 14, rtB03 17,
  rtB04 10, rtB05 5, rtB06 5, rtB07 13, rtB08 14, rtB09 5, rtB10 16,
  rtB11 12, rtB12 6, rtB13 11.
- _SPLIT_S_WORK calibration: the most Stage S DFS work on one side is
  25204 (luo below, knots.pkl) and 22761 (luo below, analytic knots,
  where nw-exact-desc is reached after three failed rules of 6300 each
  and wins at size 60); no rule that succeeds standalone is cut off on
  either input, so 30000 stays.
- Finding: on knots.pkl every S rule fails on luo below and bagajewicz
  above, in the first 3 passes and even in all 6 _SCHEDULE passes, so V
  serves them (sizes 129 and 67, the design's V alternatives). The
  facility lists the streams by duty (heats in kJ/h); in the literature
  order the same branched problems succeed at once (S:nw-exact-desc 60,
  S:partner 42). The unchanged DFS is order-sensitive: a canonical order
  of the branched side's items is a unit-count follow-up (design O-1),
  not a correctness issue (V is the backstop).
- Planner time, one round on the 38 cases: 24.6 s with Stage S + V
  against 1.3 s with V alone; 15.7 s for the 25 analytic cases.

Tests (written first; they failed on the missing Stage S candidates)
- test_split_cases_reach_mer: the whole portfolio runs (every S rule
  named, V has a key), the smallest key wins, size <= the recorded bound.
- test_split_exclusion_by_signature (all candidates): excluding the pick
  gives a different network, excluding every network in turn still
  returns one, a live preferred S rule is taken alone, an excluded one
  is not run again; the core-only part as before (S off).
- test_fuzz_splitting_always_reaches_mer: S on half the seeds
  (_SPLIT_S_WORK patched to 3000, like _BE_WORK), the core alone on the
  other half; at least 40 S picks.
- test_splitting_with_avoid_recycle_never_repeats_a_pair (the SPLIT dict
  + 17 pinch problems): no pair twice, even across the pinch; 'mer' or
  'best_effort'; repeated-pair candidates are generated and rejected.
- test_split_first_wins_is_deterministic; test_split_side_keeps_cells_
  below_Qmin (MF5: with Qmin = 3.5 the split side keeps and reports its
  2.2 and 2.4 exchangers, the unsplit side drops its 3.3 as without
  splitting); test_stage_s_cells_fold_and_sweep (fold, trunk again,
  sweep with the flex move, rejection); test_stage_s_candidates_are_
  verified; test_stage_s_budget; test_stage_s_unit_bound_and_improvement
  (the bound changes nothing but the work; the winner's improvement
  takes 7sp3 above from 8 to 7). check_split_network gains mer=False
  for best-effort networks.
- Mutation probe (scratch t4b_mutation.py): no folding, no sweep, no
  repeated-pair check, S ignoring forbid, no unit bound, a bound that
  drops ties, no shared budget, no winner improvement: each fails at
  least one new test.
- Times: the fuzz test 10.1 s, the avoid_recycle test 7.0 s (mostly the
  unsplit best effort of the other side), the rest under 0.5 s each.

Deviations from the design, with reasons
- The residual sweep also moves the later cells of the swept cell's
  flex item: adding r to the cell alone would make it overlap the next
  cell of that flex branch by r/g in parent heat. It treats |r| <= tolQ
  (an overshoot by round-off too), not only 0 < r.
- s_ok also requires the root deficit within _preleak_max: beyond it the
  pre-leaked root would book more than the cascade's own tolerance.
- _stage_s takes T3's reasons dict and only/skip names (the design has
  skip alone and a separate _generate).
- _improve_units/_units_guard gain a0 here (T5 adds b0): the winner's
  improvement needs the pre-leaked root.
- The winner's improvement also runs under _SPLIT_FIRST_WINS.

Validation
- Full suite: 513 passed in 523.99s (0:08:43) (T4a: 506 in 495.69 s).
- ast.parse(feature_version=(3, 12)) on the three changed files.
- R1 oracle, planner mode (hxn_synthesis.py and
  _heat_exchanger_network.py untouched):
  R1 oracle (planner): 78 records, sha256 7307a56778e56268, 1161.8 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
  R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
T5 of the stream-splitting design (2.4.4-2.4.6): the core backstop now
runs four strategies from the pre-leaked root, V, LV, VT and LVT, where V
was the only one. L adds pinch blocks (Linnhoff and Hindmarsh's pinch
split, as one block) at nodes where the rules fail outward; T completes
the side with the planner's own DFS from a rule-clean node. Every
candidate stays MER and verified, and the key picks among them, so the
split sides get smaller networks where Stage S fails or is beaten. With
the flag off nothing new runs; the R1 oracle below confirms it bit for
bit.

Planner (_planner.py)
- _Search gains b0=None, the flex frontiers; run() starts from
  list(b0), else [0.] * F, so the default is today's search.
  _improve_units and _units_guard gain b0 beside a0 and pass both to
  every search.

Pinch block (_pinch_block)
- Only at a node whose rules violation (on the search's analysis, as the
  DFS sees it) is 'outward'. Cut sets by _cut_sets (None if a demand is
  flat), CP transport 'mincell', else 'nw-rho-desc'; f = load / m_i,
  g = load / sum load_j (_cut_fractions), so every flex branch has at
  least its must branch's CP. All cells start at the node.
- Extents: h_i = min over must i's cells of x_max / f, x_max the
  planner's _max_duty on the branch-scaled curves (Lemma B), capped by
  the must's residual and the flex branch's room g (Qf_j - b_j). Every
  branch of a must covers the same parent range, so the must's remix is
  isothermal; flex branches end apart (a non-isothermal remix the key
  counts when the flex has a later exchanger). A demand that cannot move
  more than tolQ fails the transport.
- Tick-off: h_i >= rem_i - tolQ gives h_i = rem_i exactly, the last cell
  takes rem_i less the others and the node is Qm_i exactly (no sliver).
- Acceptance at lambda = 1, else the largest lambda found by
  _SPLIT_BISECT = 30 bisection steps, below _SPLIT_LAMBDA_MIN = 1e-3
  none: every cell passes (C) (_cell_margin) and the end node, in parent
  coordinates (Lemma R), has slack >= -_SPLIT_R_TOL tolQ. Every accepted
  lambda was checked, so nothing depends on monotonicity.

DFS tail (_tail)
- The first _SPLIT_PASSES = 3 _SCHEDULE passes from (a, b) at
  _SPLIT_TAIL_WORK = 0.2 of their budgets, then _improve_units and
  _units_guard from (a, b). The DFS closes a must within tolQ; the
  residual is swept into its far-end cell exactly as Stage S does
  (_s_cells, every item a trunk), else the tail is rejected. A tail is
  the candidate's last block (kind 'tail', every must at its end); it
  never yields a node.

Driver (_drive) and portfolio
- Per node: the exact (R) check; with T, a tail at a rule-clean node
  that is not the unleaked root (a pre-leaked root qualifies), at most
  _SPLIT_TAIL_TRIES = 3 per strategy; with L, a pinch block at an
  outward violation, at most M + F per strategy; else a vertical block
  (after a pinch block it builds a new coupling from the exact
  analysis). Failed tails' work counts in the candidate's work.
- _CORE_STRATEGIES = _CORE_ORDER = ('V', 'LV', 'VT', 'LVT'); _split_side
  runs them after Stage S, as before. Strategies that build one network
  share its signature, so excluding one excludes them all.

Corpus check (scratch t5_corpus_check.py on the 38 SPLIT cases'
round-0 knots, knots.pkl, and on the 25 constant-CP cases' analytic
knots)
- All 38 'mer'; on every split side every core candidate, not only the
  pick, passes _verify_side_cells from the pre-leaked root; picks have
  mixbad == 0; no errors.
- Picks on knots.pkl: Stage S 46, LVT 3, VT 1, LV 1, V 1 (luo below).
  No side got larger; five got smaller: bagajewicz above 67 -> 46 (VT;
  every S rule fails there), rtB01 above 10 -> 6 (LV), rtB02, rtB07 and
  rtB10 above -1 each (LVT). Network bounds (units + extra branches +
  split stages) that change from T4b: bagajewicz 69 -> 48, rtB01 14 ->
  10, rtB02 14 -> 13, rtB07 13 -> 12, rtB10 16 -> 15; the rest as T4b.
  The analytic constant-CP picks are unchanged (all Stage S).
- Planner time, one round on the 38 cases, back to back on this
  (loaded) machine: 30.4-32.4 s against 25.8-26.8 s with the core V
  alone; the analytic 25: 17.6 s (T4b 15.7 s).

Tests (written first; they failed on the missing b0, _pinch_block and
strategies)
- test_search_b0_default_is_identical: _Search at b0 None/zeros equals
  today's, _improve_units likewise; from the node after the first piece
  the search continues every flex from b and serves every must from a
  (20+ cases); _improve_units and _units_guard pass (a0, b0) to every
  search.
- test_pinch_block_acceptance: the number rule, the CP rule and a
  tick-off case ((f 400)/f rounds 6e-14 short) end at Qm exactly with
  lambda = 1; forbid, used and a non-outward violation give None; a
  hand-built side where lambda = 1 strands a must gets lambda = 0.875
  (1e-8); one where any lambda above 2.5e-7 does gives None, and LV falls
  back to V.
- test_tail_sweeps_the_residual: a flex tolQ/2 short; the tail serves the
  must exactly; a tail where the search fails is None.
- test_core_strategies_reach_mer: the four strategies on the SPLIT dict,
  five corpus cases, the near-threshold, near-double-pinch and a clean
  pre-leaked case (CLEAN_PRELEAK: the rules fail at the root and hold at
  the pre-leaked root, where VT's tail succeeds) and 1/6 of the core
  fuzz sides: exact cells, (R) at every node, no leak, pinch blocks only
  at outward violations (check_pinch_block: exact cells, isothermal
  musts, branch CPs, (R) at the end, tick-off), tails only last, trunk
  and at rule-clean nodes; summed sizes LVT < LV < V and VT < V.
- Existing tests now see four core candidates: test_exhausted_schedule_
  splits (the pick is any core strategy, VT here), test_split_exclusion_
  by_signature (core only: LV wins, LV and LVT share one network and are
  excluded together, VT next; the one-candidate part pins
  _CORE_STRATEGIES = ('V',)), test_stage_s_budget.
- Mutation probe (scratch t5_mutation.py): lambda always 1, no tick-off,
  a pinch block ignoring forbid/used, a tail at the unleaked root, no tail
  sweep, no tails, no pinch blocks, _Search ignoring b0, _improve_units
  or _units_guard dropping b0: each fails at least one new test.

Test helper fix (_verify_side_cells, the cause of one scratch failure)
- On cgm_balanced8 above, LV's and LVT's vertical block after the pinch
  block has two cells whose approach closes linearly from 8.4e-9 K to
  exactly 0 (margin 0.0 by _cell_margin; (C) holds). The helper's
  _max_duty cross-check stopped 0.043 kW short (tolQ 2e-4): _max_duty's
  linear case stops at the zero crossing, g0 / (sf - sm), and with the
  branch slopes 1.4e-9 apart (just above _SLOPE_EQ) one ulp of the
  ~500 K levels in g0 moves that crossing by ~0.3 kW. The cross-check now
  runs _max_duty against the flex lowered by tolP with tolP = 0, which is
  (C) exactly (phi - psi >= -tolP) and well conditioned; the direct
  evaluation beside it is unchanged, so no tolerance is loosened.

Deviations from the design, with reasons
- The pinch block's acceptance uses the exact analysis (_exact), not the
  search's: the driver checks every node exactly (T2), and the search's
  analysis omits slivers up to tolQ, so a node it accepts could fail the
  driver's next check with _SplitInvariantError.
- 'nw-rho-desc' is also tried when the 'mincell' block fails (a demand
  that cannot move, a forbidden or used pair, lambda below the minimum),
  not only when 'mincell' finds no transport (it then falls back to the
  north-west corner itself, and neither exists when sum c < sum m).
- A pinch block with a forbidden or used pair is None (V serves the
  node): _cut_transport has no forbidden pairs.
- _pinch_block takes the driver's violation (viol=); _tail returns
  (block, work) and is a _Block, so the candidate, its verification and
  its records treat it like any block. With cap1 the tail forbids the
  earlier blocks' pairs.

Validation
- Full suite: 517 passed in 362.72s (0:06:02) (T4b: 513 passed in 523.99 s).
- ast.parse(feature_version=(3, 12)) on the three changed files.
- R1 oracle, planner mode (hxn_synthesis.py and
  _heat_exchanger_network.py untouched):
  R1 oracle (planner): 78 records, sha256 7307a56778e56268, 793.4 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
  R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…stics

Milestone A review of the planner-level splitting (T1-T5b). Every finding
was reproduced before it was acted on; all were confirmed.

Major 1, the core backstop failed on near-flat pieces. `_exact` reused
`_Side._R`, whose multi-piece path evaluates a stream's residual as
`B * (L - D)` with B and D cumulative sums of the pieces' slopes and
offsets. On a near-flat piece (a glide of 1e-7 K over 10 of heat has a
slope of 1e8) the sums cancel catastrophically and the residual drifts
with the level, past the stream's own heat (q = [0, 10, 110, 115],
y = [-110.0000001, -110, -60.0000004, -60.0000004] gives 115.00000006593
at the top level, Q = 115). Every core invariant reads these arrays, so
on jittered data all four core strategies raised 'core node (R): slack
-2.13e-08' (a 0.026 tolQ root deficit) and the side fell back to best
effort. `_exact` now returns an `_ExactSide`, whose `_R` adds each
piece's clipped share directly (`clip((L - lo)/(hi - lo), 0, 1) ln`, flats
by level), so a share never leaves [0, ln] and the arrays are exact to
round-off. The search keeps its own arrays, so the unsplit planner is
unchanged (R1). Follow-up for the user, not done here: the same drift
reaches the unsplit DFS through `_Side._R` (57 tolQ in the one-stream
example); fixing it there changes R1.

Minor 2, Stage S plans failed verification on near-parallel pairs. The
planner's `_max_duty` takes linear slopes within a relative `_SLOPE_EQ`
(1e-9) as parallel and returns the full limit, so a converging pair may
close by 1e-9 times its level span: on jittered case #92, S:demand paired
a branch with a flex whose slopes differ by 9.997e-10 over 25 K, and
`_Candidate` found the cell failing (C) by -2.5e-8 against tolP 2.7e-9
(an invariant error, the candidate lost). The cause is in the search, so
it is fixed there rather than after it: `_Search` now asks its side for
the max duty (`_Side.max_duty`, the planner's `_max_duty`: bit-identical
for the unsplit planner), and the split path's searches (Stage S's
branched sides, the core's DFS tails and their unit improvements) run on
a `_StrictSide`, whose `_max_duty_strict` applies the parallel shortcut
only where the gap at the limit stays >= -tolP, and otherwise returns the
duty of any converging pair, max(g0, 0)/(sf - sm). `_pinch_block`'s
extents use it too. Shortening the failing cell afterwards (the review's
first option) would have cut it from 25 to about 2.7, far beyond the tolQ
the residual sweep may move, so it amounts to rejecting the candidate;
with the strict search S:demand now yields a verified candidate on #92.
The module docstring (Lemma 1, Stage S, Strategies) and the hand-off
design's Theorem S step 1 now say that `_cell_margin` establishes (C) at
tolP and the split path searches strictly.

Minor 3-8: `_split_side` no longer drops a failed attempt. It always
returns a side plan; without a candidate it has status 'failed' and the
attempt's split info (candidate and signature None, the reasons, the
errors, the root deficit), which `_plan_side` keeps on its best-effort
plan, with the work spent. The work spent now also counts every Stage S
rule's DFS passes, not only those that produced a candidate
(`_stage_s` returns its work). The unused `d` parameter of `_split_side`
and the dead `_Candidate.a0` are removed; the key reads `units +
branches` (units + extra branches + split stages). The generator name is
no longer repeated in error messages ('S:demand: S:demand: ...'). Code and
tests no longer cite the locally excluded design document (design 2.x,
MF4/5/9/19, R2, R1 oracle); they point to the module docstring sections
or say what the label meant.

Tests (major 9 and minor 10-16):
- check_split_network asserts `Split.isothermal` against the walked
  branch ends (parent-equivalent, within _ISO_TOL tolQ of H_mix) and that
  a non-isothermal remix is the stream's last item;
  test_split_cases_reach_mer asserts mixbad == 0 for the pick.
- test_search_b0_default_is_identical spies on a real side (M, F >= 1)
  with distinct a0/b0; test_vertical_block_forbid_and_used counts its
  avoid_recycle candidates (it checked none: coarsening was off there);
  test_exhausted_schedule_splits asserts the work handed to the split
  path equals the best effort's and the side's work is that plus every
  core candidate's; the docstring-phrase assert is dropped; the slow
  fixture comment names the one fixture skipped.
- test_candidate_key_and_signature covers the mixer cap (three isothermal
  stages of a curved flex: mixbad 1; two: 0; a straight flex: 0).
- New: test_exact_residual_arrays_on_near_flat_pieces (exact arithmetic
  reference), test_core_on_near_flat_pieces_reaches_mer (case #740),
  test_split_searches_keep_near_parallel_pairs_feasible (case #92),
  test_failed_split_keeps_its_diagnostics,
  test_split_signature_is_knot_independent (knots 1 vs 3 on the SPLIT
  dict and three corpus cases), test_curved_split_plans_walk_on_the_knots
  (plan_network on point-load and CP-change variants of the number and CP
  rules plus 60 pinch-centred curve problems, checked by an independent
  knot walk: records, fractions, mixes, isothermal flag, closure, the
  approach at every knot, MER against knot_cascade).
Mutations checked in scratch: the isothermal flag forced False, or on the
scale instead of tolQ, or exact; the mixer cap as >=, on n > 1, or
dropped; the strict max duty made lenient; `_exact` on `_Side`: each
fails a test.

Validation: the review's jittered piecewise probe (reviewA_probe4, in
scratch) found, before: 1 of 1000 plans not MER (full portfolio, seed 2)
with 71 V/LV/LVT and 6 Stage S invariant errors, and 6 of 500 not MER
with V alone. After:
  full portfolio, seed 2: 1000 problems, 142 split, 0 not MER, errors {}
  V alone, seed 0:        500 problems, 74 split, 0 not MER, errors {}
  V alone, seed 2:        500 problems, 79 split, 0 not MER, errors {}
Python 3.12 syntax (ast.parse, feature_version=(3, 12)) of the three
changed files: ok.

Full suite (-m pytest . --disable-numba=1 -q -p no:cacheprovider):
523 passed in 300.97s (0:05:00)   (517 before, +6 new tests)

R1 oracle --check (planner mode; hxn_synthesis.py and
_heat_exchanger_network.py unchanged):
R1 oracle (planner): 78 records, sha256 7307a56778e56268, 660.2 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Task T6 of the stream-splitting design (section 4.4). The synthesizer's
exact-state layer assumed every exchanger runs its streams' full flow:
`_walk` moved each stream by the full duty along `plan.stages`, and
`_exchanger_approach`/`_shrink` mapped duty position q to H_in - q. On a
branch of flow fraction f the parent-equivalent enthalpy moves by Q / f,
so a split plan's branch exchangers would have been checked, repaired and
realized as full-flow exchangers (e.g. rtB05's walk put C1's cold branch
outlet 168582 kJ/hr short of the plan's).

- `_walk`: `if not plan.splits:` the old loop verbatim; otherwise walk
  `plan.paths`: trunk exchangers as before, a split's branches from the
  split enthalpy by duty / f (`_walk_split`), and the mix at H_split -/+
  the fsum of the surviving branch duties (the planner's `_walk_paths`
  arithmetic, so the ends equal the planner's record enthalpies bit for
  bit on the hand-built plan). Still the 3-tuple `(ends, last,
  pair_index)`; `ends` are parent-equivalent. A dropped branch exchanger
  shortens its branch, an emptied branch bypasses (ends at H_split).
- `_split_nodes(plan, duties, ends)` (new): `first_nodes[j]` = the first
  surviving exchanger of every branch of the split that is stream j's
  first node (() otherwise), `split_ends[k] = (H_split, [H_end_b], H_mix)`
  from the walk arithmetic, None for a split with all branches dropped
  (not realized; the stream passes it unchanged).
- `_exchanger_approach(..., fh=1., fc=1.)`: H_out = H_in -/+ Q / f, knot
  positions (H_hot_in - H) fh and (H_cold_out - H) fc, exact states at
  H_hot_in - q / fh and H_cold_out - q / fc; violation states are
  parent-equivalent, so `_refine_knots` is unchanged. At f = 1 every
  expression is the old one bit for bit (x / 1. and x * 1. are exact; the
  association of H_lo + H_in - q is kept).
- `_shrink(..., fh=1., fc=1.)`: the same mapping; the CP guess uses the
  branches' f CP. Monotonicity holds on branches (cold position
  H_cold_in + (Q - q) / fc falls with Q, the hot one does not depend on Q).
- `_exact_approach` and `_repair` pass `e.hot_frac, e.cold_frac`.
- `_enthalpy_limit` call site (`_realize`): judged on the full-flow state
  (parent basis) and multiplied by f; the branch inlet is the full
  stream's state at the branch inlet enthalpy (the real inlet for the
  first exchanger of every branch of a first-node split, via
  `_split_nodes`), scaled by f. `plan.splits` is empty without stream
  splitting, so nothing changes there.

Tests (tests/test_hxn.py, written first; 7 failed / 1 passed before the
implementation for the right reasons: no fh keyword, no `_split_nodes`,
the unsplit walk):
- test_branch_exchanger_approach_matches_scaled_curve[constant_cp, glide]
  (glide: rtB07's water/methanol boiler C3 vs hot water): the check with
  fractions on the parent curves equals `_exchanger_approach` on the
  StreamCurves of the scaled streams (worst difference 0.0 K), every
  state within 1e-9 K of the scaled curve's T_exact (measured 1.0e-10 K),
  both branches at the same duty, every knot inside the branches a
  position, a 0.5 K violation found with a finite T_min_app,
  `_exact_approach` passes the fractions, f = 1 identical to the default.
- test_shrink_with_fractions_is_monotone[constant_cp, glide]: approach
  non-increasing in the duty, feasible set [0, Q'], `_shrink` tight and
  equal to the scaled-curve shrink within 1e-8 Q, closed form for
  constant CP.
- test_walk_split_paths: T3's hand-built records plan: ends == the
  planner's record enthalpies exactly, full / dropped / shrunk / all-empty
  branches, pair_index, `_split_nodes`, and the unsplit path.
- test_realize_split_branch_ports[smith2005_ex18_2_split, rtB05]: split
  plans on round-0 grid knots: walk vs planner, `_split_nodes` vs
  Split.H_split/H_mix, exact approach clean (smith), realized branch
  HXprocess units at f of the flow with inlet/outlet H at f x the planned
  parent state (<= 2e-14 x duty measured), H_lim = f x the parent's,
  first-node branches on the real inlet (smith splits at an inlet, rtB05
  after a trunk exchanger), internal approach >= dT (smith).
- test_repair_shrinks_branches_with_fractions: dT + 5 K on smith:
  one pass, `_shrink` called with the fractions, the repaired plan clean
  on the exact states and on the realized units.
Mutation check (scratch t6_mutation.py): 12 source mutations; all but one
fail a test. The survivor drops fc from the cold stream's knot-screen
(`planned`) position; with fc <= 1 that only underestimates the planned
approach, so more intervals get the exact check and no result changes.

Validation:
- Full suite: 531 passed in 224.92s (0:03:44) (523 before, +8 new).
- ast.parse(feature_version=(3, 12)) on both changed files.
- R1 oracle --check (full mode, hxn_synthesis.py touched):
R1 oracle (full): 93 records, sha256 6c98f043a59a2e5b, 108.6 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
R1 oracle (full): IDENTICAL to r1_full.json (stored sha256 6c98f043a59a2e5b)
- R1 oracle --check (planner mode):
R1 oracle (planner): 78 records, sha256 7307a56778e56268, 430.8 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ze_network

synthesize_network gains stream_splitting=False (after info). With it on,
split plans are realized end to end and the exact-check loop can re-plan a
split side; with it off, nothing changes (R1, oracle below).

Realization (design 4.5). New StreamSplit (documented, not in __all__) and
_realize_splits, run after _realize inside the same retry loop, only when
plan.splits. For every split that keeps an exchanger (an all-empty split is
a trunk, as _split_nodes says), a chain of len(fractions) - 1 bst.Splitter
units, IDs Split_<j>_<hs|cs>[_k] then _b<c>, element c splitting
f_c / fsum(f_c..f_n) (tail sums: no cancellation, the last element's rest is
its exact complement) and feeding element c+1 through its second outlet; the
feed is the real inlet (_copy + _first_inlet) when the split is the stream's
first node, as its branch exchangers' inlets already are, else
state_at_H(H_lo + H_split). Then one bst.Mixer(rigorous=True,
thermo=outlet.thermo) Mix_<j>_<hs|cs>[_k]: inlet b is f_b of the stream at
the mix state when every branch ends within _ISO_TOL tolQ of H_mix (the
planner's isothermal criterion, evaluated on the realized walk so a dropped
or shrunk branch is handled), else at its branch end; the outlet starts at
the planned state. The ordinal k counts realized splits per stream and side
in flow order; position counts live trunk exchangers before the split.
Deviations use one rule for every mixer: |H - sum H_in| > _DUTY_TOL x stream
duty or |T - T_plan| > _MIX_T_TOL = 1e-7 K, reported as dict(ID, T_plan, T,
H_plan, H) with H_plan = sum H_in (the checked quantity). A Splitter or
Mixer whose _run raises discards every unit built and raises
_SplitRealizationError(k, [(n, ID)], error); the retry loop drops that
split's branch exchangers (the split becomes a trunk), so every failure
removes at least one exchanger and the loop ends. Utility inlets,
stream_HXs_dict (plan.stages is topological) and the info utility sums need
no change, as designed.

Refine loop (design 4.6). n_rounds = _MAX_REFINE + (_MAX_SPLIT_RETRY = 2 if
stream_splitting). After _MAX_REFINE, a round runs only if a violating
exchanger lies on a split side whose signature is newly excluded (keyed on
split['candidate'] is not None, per review A); otherwise the loop stops
where the default loop stops. The best MER plan (fewest violating
exchangers, earliest round) is kept and restored with its knots if the
retries end worse. info gains 'stream_splitting', 'splits' and
'split_deviations' only when the flag is on. ValueError without info.

Stickiness: signature-aware (the T5b refinement, adopted). _split_prefer is
now side -> (candidate name, network signature) of the previous pick, and
_split_side takes the regenerated preferred candidate as is only if it is
live AND its signature is unchanged; otherwise the whole portfolio runs (the
preferred generator not re-run; its candidate competes on the key). Why:
T5b found that on rtB07's refined knots the preferred S:partner below grows
from 3 to 4 exchangers under a new signature, so name-only stickiness costs
an exchanger. Measured at synthesis (scratch t7_probe.py): rtB07 now picks
S:demand below in round 1, 8 process exchangers in 1.16 s, against 9 in
0.57 s with name-only stickiness; the other 37 SPLIT cases need no refine
round and are unchanged. Guarantees kept: R3 (no split side: prefer == {},
exclude never grows, the loop is the default one); the MER argument of 2.7
step 2 (a round either takes a live MER candidate or runs the whole
portfolio including V from a0); termination (the loop is bounded by
n_rounds; the retry exclusion still makes every retry plan a different
network, since an unchanged preferred signature is excluded and a changed
one is only taken through the key among live candidates). Planner tests
updated to the (name, signature) form, plus the stale-signature case.

Tests (written first; they failed on the missing keyword and on the name-only
prefer): test_synthesize_network_splitting_requires_info,
test_synthesize_network_with_splitting (smith2005_ex18_2, rtB05, the crude
unit with >= 3-branch chains, rtB03 with a vapor split at its inlet),
test_split_mixer_outlet_is_the_planned_state (+ the deviation rule),
test_unsplit_refine_loop_identical_with_splitting (R3 through the loop),
test_split_retry_changes_the_candidate, test_split_retry_restores_the_best_plan
(restore; beyond the design list), test_split_realization_failure_merges_the_split;
test_split_exclusion_by_signature extended. 15 source mutations (scratch
t7_mutation.py/.out) each fail a test. Scratch probes: every one of the 38
SPLIT cases synthesizes to 'mer' with no split deviation, drop or repair
(t7_probe_rt.out, t7_probe_cp.out; 8.8 s and 10.8 s in total).

Validation:
R1 oracle (planner): 78 records, sha256 7307a56778e56268, 433.7 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268)
R1 oracle (full): 93 records, sha256 6c98f043a59a2e5b, 109.8 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
R1 oracle (full): IDENTICAL to r1_full.json (stored sha256 6c98f043a59a2e5b)
Full suite: 542 passed in 227.44s (0:03:47) (531 after T6; +11 new test items)
ast.parse(feature_version=(3, 12)) clean on every changed file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tests

Review milestone B (HS diff T6-T7) findings, each verified before acting.

Major, split network identity (hxn_synthesis refine loop, _splitting
exclusion and stickiness). A split's signature carried its fractions to 9
significant digits and was compared exactly, but the fractions are CP
ratios on the knots, so every refine round on real thermo moves them
(~1e-7 to 1e-6). Reproduced on the review's random real-thermo problem
(seed 172, 3 hot, 2 cold, T_min_app 5 K): the preferred S:partner never
matched, so the whole 11-candidate portfolio ran in every refine round,
and each retry "excluded" a signature the next round no longer produced,
re-planning the same network with the same violating exchanger in all 6
rounds (12.0 s). Structure-only identity is not the fix: a probe over the
28 constant-CP SPLIT problems (fixB_sigprobe.py) finds 47 candidate pairs
of one side with the same structure but fractions 1.57e-2 to 0.45 apart,
i.e. genuinely different networks. So the new `_same_network(a, b)`
compares the structure exactly and each fraction within
`_SPLIT_SAME_FRACTION = 1e-3` (15x below the smallest distinct
difference, >1000x above the measured drift; equal to the smallest branch
fraction planned). `_excluded`, the stickiness test in `_split_side` and
the retry loop's "newly excluded" test use it; the 'knot-independent'
docstrings are corrected. On seed 172 every refine round now re-plans
S:partner alone (1 candidate instead of 11), the retries pick two other
networks (S:nw-rho-desc, then VT), which do no better, so the round-0 plan
is restored and repaired as before; the run takes 4.7 s instead of 12.0 s
(test_split_identity_survives_refined_knots, which fails under the old
exact comparison at round 1).

Minor, rejected in part: the mixer-flash premise. The docstring claim that
the flash of the mix lands on the planned state is wrong and is fixed (the
seed only speeds up the flash; `_MIX_T_TOL` catches thermosteam's PH
flash disagreeing with the curve, R-2). The suggested policy (merge a
deviating split whose stream continues into a process exchanger) is not
adopted: on seed 54 a PH flash at H_mix lands at 363.2951291042193 K from
every start state tried (the planned state, the split state, the stream's
inlet; curve states 360.7-362.5 K), so a process exchanger ending in that
band lands there too, split or not (the design's G7 exposure). Merging the
split would cost MER without removing it. Deviations stay reported.

Minor, fixed: retry docstrings (a retry re-plans the side without its
excluded networks: another one if left, else the same one on the refined
knots, which ends the retries); dead `ends`/`violations` in the restored
`best`; `_walk` docstring wording; the first-node rule is now computed
once, in `_split_nodes` (new third return value `first_splits`), and
`_realize_splits` takes the splitter feed's real inlet from it instead of
its own scan.

Tests: branch first exchangers of an inlet split take the real inlet
(fails on rtB03 when the rule is removed: 1.7e-13 K); the mixer report's H
rule alone (`_DUTY_TOL` = -1) with T_plan/H_plan values; one Mixer._run
per mixer (spy); no 'replaced in registry' warning after a failed split
realization (fails with the HX units' warnings when they are not
discarded);
a stream split twice on one side (IDs Split_2_hs / Split_2_hs_2 /
Mix_2_hs_2, positions 0 and 1, only the first split on the real inlet);
drift-tolerant exclusion and stickiness at planner level. Tautological
f = 1 assertions removed (the R1 oracle's full mode covers f = 1); the
glide shrink sweep uses 8 duties (16.1 s -> 8.0 s).

Validation: full suite 545 passed in 220.36s (was 542 in 237.24s);
py3.12 ast.parse on every changed file. R1 oracle --check:

R1 oracle (planner): 78 records, sha256 7307a56778e56268, 436.3 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268)
R1 oracle (full): 93 records, sha256 6c98f043a59a2e5b, 110.0 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
R1 oracle (full): IDENTICAL to r1_full.json (stored sha256 6c98f043a59a2e5b)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…umns

A synthesized split network (T7) has stream paths that are no longer a
chain: a split stream passes a splitter chain, parallel branches of
exchangers carrying a fraction of its flow each, and a rigorous mixer. The
life cycle recovered from the exchanger IDs could only sort stages by inlet
enthalpy, which is meaningless across branches (a branch stage's H_in is f
times the parent's), and its first stage no longer carries the whole
stream's inlet when the stream splits at its inlet. The facility (T8b)
needs the life cycles to say how to wire such a network.

Life cycles (design 5.1, additive):
- LifeStage(unit, index, branch=None, fraction=1.): branch stages carry
  (k, b) and their branch's fraction; their repr/_info name the branch.
- StreamLifeCycle.splits (always a list, [] when unsplit), entry (a _Port
  namedtuple: the first splitter's inlet if the stream splits at its
  inlet, else the first stage's) and H_in (full-flow inlet enthalpy at
  entry; equals life_cycle[0].H_in when unsplit).
- get_life_cycle(new_HXs, new_HX_utils, splits=None): with no split of
  this stream (splits of other streams are ignored) it runs exactly
  today's code. With splits, the stream's splits are sorted into flow
  order by (position, sign * H_split) (consecutive splits share a
  position), branch exchangers are tagged by identity, only the trunk
  stages (full flow, monotone in H) are sorted by today's key, each split's
  branch stages are inserted branch-major after `position` trunk process
  stages, and the utility stays last. No enthalpy is compared across
  branches; the order equals synthesize_network's stream_HXs_dict.
- connections() yields (up_unit, up_port, down_unit, down_port) in flow
  order (trunk -> splitter 0, chain c.1 -> c+1.0, outlet(b) -> branch or
  mixer.b for a bypass, branch -> mixer.b, mixer.0 -> next); for an unsplit
  stream exactly the consecutive stage pairs.
- stage_pairs(): exchanger precedence through splitters and mixers (the
  contraction of connections()); a bypass carries the stages before its
  split past it; siblings get no pair.
- repr adds one "split k: n branches (f...)" line per split after the
  stage list (no kJ/hr, so the stage-line count is unchanged).

Pinch diagram (5.7): _order_exchanger_columns keeps today's code for any
life cycle without splits (getattr(lc, 'splits', None), so the duck-typed
fakes of test_pinch_diagram_column_order_follows_stream_direction keep
working unchanged); a split life cycle orders its requested exchangers by
their nearest requested successors along stage_pairs() (same partial
order as the transitive closure, so precedence carries through excluded
stages), reversed for hot streams, none between siblings.
plot_pinch_diagram labels the stream ends with lc.H_in and the utility's
H_out, and the two H texts get gids 'H_in:<i>' / 'H_out:<i>' (SVG ids only;
PNG output unchanged). save_stream_life_cycles_as_csv writes lc.H_in for
stage 0.

Tests (written first; they failed on the missing splits= keyword):
test_split_life_cycles (smith, rtB05, crude, rtB03 networks from T7:
order == stream_HXs_dict, split order, branch/fraction tags, entry, H_in,
repr lines, every port fed/feeding once, each connection's outlet carries
the next inlet's flow (1e-12) and enthalpy (1e-9 x duty; measured max
4.4e-12 x duty), stage_pairs == contraction of connections, the explicit
smith and crude wiring, ValueError for a foreign branch exchanger);
test_life_cycle_connections_through_bypasses (hand-built, consecutive
splits with bypasses); test_split_exchanger_columns (cold and hot);
test_split_network_pinch_diagram (synthesis-level part: HX gids, full-flow
H labels, which differ from the first branch stage's, and column order vs
reachability for every exchanger pair of every split life cycle, both
given orders). A scratch mutation probe (late split insertion, wrong mixer
port, branch H label, hot not reversed) was caught by every mutation.

Validation: full suite 554 passed in 227.52s (was 545); py3.12 ast.parse
on every changed file. R1 oracle --check:
R1 oracle (planner): 78 records, sha256 7307a56778e56268, 455.6 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268)
R1 oracle (full): 93 records, sha256 6c98f043a59a2e5b, 113.2 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
R1 oracle (full): IDENTICAL to r1_full.json (stored sha256 6c98f043a59a2e5b)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…xn_mer

Task T9a of the stream-splitting design: before the facility splits
streams (T8b), the tests get a checker that can judge a split network,
and the checker is itself tested.

Harness:
- _network(case, stream_splitting=False, variant=None) caches per
  (name, stream_splitting, variant) and records both in the network dict.
  variant='V' (the backstop alone: no Stage S rule, core strategy V only)
  patches _splitting inside the synthesis call only (_variant, with
  pytest.MonkeyPatch.context()), so a cached variant never leaks the patch
  (test_network_variant_is_patched_for_the_synthesis_only). The facility
  gets stream_splitting=True only when asked; until T8b adds that keyword,
  flag-off networks are built exactly as before.
- _corpus_knots(case): the planner inputs of synthesize_network's round 0,
  cached, with the seconds taken (0.9 s for all 78 cases). A scratch check
  that spies on plan_network during every flag-off facility synthesis
  first found smith2005_ex16_1 different: two coolers tie at 2880 kW, the
  facility takes its heat utilities in System.from_units order, and the
  stable sort by duty keeps that order for the tie, while the build order
  (used by the R1 oracle's planner mode and proto/dump_knots.py) puts them
  the other way round. _facility_heat_utilities now reproduces the
  facility's order; all 78 cases are then bit-identical to the facility's
  round-0 knots, is_hot and T_min_app.

Checker:
- _network_problems dispatches: without splitting, today's body, moved
  verbatim to _linear_network_problems (only the name changed; the
  existing tests are unchanged); with it, _split_network_problems, plus
  the linear checks when the network has no splitter.
- _split_network_problems implements G0-G10 of design 6.1, each problem
  tagged with its check. _walk_stream walks each stream on the actual
  stream graph (identity, s.sink, the port index there): HXprocess on the
  same port, a splitter tree (one split's Split_<j>_<hs|cs>[_k] chain) to
  one mixer whose inlets are exactly the branch ends, on to the stream's
  own utility. The bookkeeping (facility lists, life cycles, their
  connections and splits, stream_HXs_dict, stage fractions) is checked
  against the walk. Tolerances are the design's (flows and fractions
  1e-12, splitter T 1e-9 K, mixer equilibrium and isothermal re-joins
  1e-6 K, enthalpies DUTY_RTOL x duty, the minimum fraction 1e-3) and are
  documented in the module docstring with G0-G10.

Tests (written first; they failed on the missing infrastructure):
- test_split_checker_agrees_on_linear_networks: both checkers find no
  problem on every corpus network without splitting (all 78, not a
  sample: 5.7 s).
- test_split_checker_detects_mutations: smith2005_ex18_2 with splitting,
  wired by hand as the facility will wire it (_wired_split_network:
  synthesize_network(stream_splitting=True), life cycles with the splits,
  the entry copy, every connection, a System over a topological path of
  the connections), passes; each of 14 mutations, applied to a fresh
  synthesis, is caught by the check made for it: G0 misnamed mixer and
  unlisted splitter, G1 entry moved into a branch, G2 mixer inlet taken
  from another stream's trunk, G3 mixer inlets swapped (caught by G3's
  edge check alone) and a bypassed branch exchanger, G4 a wrong split
  ratio (re-simulated) and a mis-scaled branch inlet, G5 a mixer outlet
  off its inlets, G6 a branch past the stream's outlet, G7 a branch
  exchanger's H_lims moved past its approach (re-simulated; its dT guard
  lowered, which otherwise caps the duty at the terminals; branch 1,
  since branch 0's partner is heated by the split stream below the pinch
  and the converged loop lowers its inlet, keeping the approach and
  leaving only the non-isothermal re-join that G5 reports), G8 a utility
  short of flow, G9 a shifted outlet, G10 misreported heat.
- test_no_split_plan_unchanged_by_splitting (40 NO_SPLIT): with the flag
  the plan equals the default one bit for bit (float.hex): exchangers with
  every field, stages, paths, utilities, targets, info apart from each
  side's 'split' (None), no splits.
- test_split_cases_keep_unsplit_sides (38 SPLIT): every side that the flag-
  off plan serves ('mer', or 'trivial') has, with the flag, split None,
  bit-equal side info and the same (side, hot, cold, Q) per stream in
  flow order. The flag-off reference is planned with _planner._best_effort
  patched to leave a side unplanned: without avoid_recycle plan_network
  plans each side on its own, and only a side that ends 'best_effort'
  reaches _best_effort, so every compared side is exactly as in the real
  plan (scratch: 24 compared sides identical with and without the patch),
  and the best-effort searches (93.9 s of flag-off planning on the 38
  cases, against 0.3 s patched) are skipped. The test asserts every
  other side went through the patch and that the design's examples
  (smith2005_ex18_2 below, rtB05 below, luo above) are compared.

Time: the TM additions take 28.6 s (keep-unsplit-sides 18.2 s, linear
agreement 5.7 s, NO_SPLIT identity 4.2 s, mutations 0.45 s).

Validation: full suite 725 passed in 256.30s (T8a: 554 passed in 227.52s);
py3.12 ast.parse on the changed file. No library file changed.
R1 oracle --check:
R1 oracle (planner): 78 records, sha256 7307a56778e56268, 437.7 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268)
R1 oracle (full): 93 records, sha256 6c98f043a59a2e5b, 111.5 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
R1 oracle (full): IDENTICAL to r1_full.json (stored sha256 6c98f043a59a2e5b)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cles

Task T8b of the stream-splitting design (sections 5.2, 5.3, 5.5): the
fresh path of HeatExchangerNetwork synthesizes, wires and simulates a
network whose streams may split. The cached path and avoid_recycle are
T8c's.

HeatExchangerNetwork:
- stream_splitting=False keyword (last, so positional calls keep their
  meaning), documented in Parameters and in the Notes; passed to
  synthesize_network, and recorded as _synthesis_options for T8c's cache
  key.
- new_splitters and new_mixers: the realized splits' splitter chains and
  mixers (info['splits']), always set, empty without splitting. They join
  the unique-ID assertion and HXN_sys; their costs are 0.
- _get_stream_life_cycles gives each stream its own splits; a stream
  without any gets today's call.
- Each stream enters at its life cycle's entry port (its first stage, or
  the first splitter where it splits at its inlet) as a copy of its real
  inlet, and _enter_network now also flashes a point-load stream that
  enters at a Splitter to its equilibrium state at the inlet enthalpy, as
  synthesize_network planned and as its unsplit first exchanger takes it.
- Wiring and _network_path follow StreamLifeCycle.connections(): for an
  unsplit stream these are exactly its consecutive stages in order, so the
  assignments, the successors and the waiting counts, and so the path and
  the recycles, are those of today (the old loop's `if s_out` was
  `is not None`: a Stream is always truthy).
- _stage_fractions: a branch stage's limit is f times the whole stream's,
  so its share is recorded on the whole stream's basis (H_lim / f) and f
  in the new _stage_scales (branch ports only); unsplit stages have
  fraction 1. and are unchanged.

Tests (written first; all failed on the missing keyword):
- test_hxn_mer: _network passes stream_splitting to the facility always
  (_synthesize, _network_record), cached=False builds a fresh network,
  G0 reads new_splitters/new_mixers without a default, and the 14
  mutation tests run on the facility's own network too (28 cases).
- test_hxn: assert_path_follows_streams follows connections() and the
  splitters and mixers; test_stream_splitting_is_off_by_default,
  test_split_network_units_and_ids (smith2005_ex18_2),
  test_synthetic_network_splits_to_mer (regression case 04: 'mer'),
  test_split_stage_fractions, test_split_network_registers_no_intermediate_streams
  (two syntheses of case 04 replace nothing, splitters, mixers and their
  streams are in the network's flowsheet, the main flowsheet gains no
  stream; the doctest system is unchanged by the keyword),
  test_point_load_enters_before_the_splitter (fresh),
  test_split_branch_dropped_is_bypassed, test_split_side_keeps_cells_below_Qmin_facility,
  and facility parts in test_synthesize_network_with_splitting (same
  exchangers and splits as the direct synthesis, strict checker clean,
  MER) and test_split_network_pinch_diagram (full-flow H labels). Every
  facility network here passes the strict checker (G0-G10).
- The point load: no corpus case splits one at its inlet (T7), and a
  point load never needs a split (it keeps one temperature, so its
  matches run in series: the probe's unsplit plan is 'mer'). The test
  forces the split: force_split makes the side's root check see a
  'cascade' root (a deficit within the cascade's tolerance), so the side
  is planned with splits by the core (split-V splits the superheated
  water/ethanol reboiler of test_non_monotone_stream_is_a_point_load at
  its inlet, 0.698/0.302, against two hot water streams). The splitter
  feed and both branch inlets are at 353.07 K (its equilibrium state at
  the feed enthalpy), not the 365 K feed.
- The bypass: _realize fails once at a branch exchanger. rtB05's
  re-join feeds its heater and the strict checker is clean.
  smith2005_ex18_2's split re-joins before its trunk exchanger below the
  pinch; with a branch bypassed the re-join is no longer isothermal, and
  the checker reports exactly that G5 problem ('a non-isothermal re-join
  feeding HX_2_1_cs, not the utility') and nothing else. The design
  expected "clean apart from MER" there; that is unreachable for that
  topology, and the G5 rule is kept strict.
- Qmin = 2.5e7 kJ/hr on smith2005_ex18_2: the split side keeps its
  1.44e6 kJ/hr branch exchanger and reports it in split['small'], the
  unsplit side drops its 2.16e7 kJ/hr exchanger into qmin_dropped.

Mutations (scratch t8b_mutation*.sh): the Splitter left out of
_enter_network (1 test fails), the path from consecutive stages (4), no
branch scale in the stage fractions (4), life cycles without splits (30),
no mixers listed (31).

Validation: full suite 751 passed in 253.14s (baseline before the change:
725 passed in 271.80s; +26: 12 test_hxn cases, 14 facility mutations).
py3.12 ast.parse on the three changed files.
R1 oracle --check:
R1 oracle (planner): 78 records, sha256 7307a56778e56268, 443.2 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268)
R1 oracle (full): 93 records, sha256 6c98f043a59a2e5b, 114.8 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
R1 oracle (full): IDENTICAL to r1_full.json (stored sha256 6c98f043a59a2e5b)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Task T8c of the stream-splitting design (sections 5.4, 5.6): the cached
path of HeatExchangerNetwork now reuses a split network correctly. With
stream_splitting off nothing changes: the cache key is always true, every
f is 1, and the pre-copy of an exchanger is today's code verbatim (R1
oracle identical, including its cache and avoid_recycle runs).

Causes (each shown by a test that failed before the fix):
- The cached path copied each stream's real inlet into its first stage,
  life_cycle[0]. For a stream split at its inlet that stage is the first
  exchanger of branch 0, so the whole feed went into one branch while the
  splitter's own feed kept the last run's flows: with feeds 10 % larger
  the outlets were 10 % short and the facility warned "cache algorithm
  failed" and synthesized again (smith2005_ex18_2, the forced point-load
  split). It now enters at lc.entry and calls _enter_network with it, as
  the fresh path does (a point load is flashed before the splitter).
- The pre-copy moved flows by zip(unit.ins, unit.outs): a splitter's
  outlet 0 got the whole feed (and outlet 1 kept its old flows), a
  mixer's outlet only its first inlet's flows. The new _move_flows keeps
  that code for exchangers, runs Splitter._run for a splitter (its fixed
  split; it copies T, P and phases and flashes nothing) and gives a
  mixer's outlet the summed inlet flows by the same rule (a multiphase
  outlet: flows scaled, and if that does not reproduce them, the inlets'
  phase flows and a TP flash at its own T and P). The rigorous mixer
  (a PH flash) runs only inside the guarded sys.converge().
- A branch stage's limit was restored on the whole stream's basis: its
  recorded share (T8b's _stage_fractions, parent basis) must be scaled by
  its flow fraction f (_stage_scales). H_lim = H_in + share*(H_out - H_in),
  then *= f when f != 1; the partner rule (port 0 at the outlet when port
  1 has a limit) gives f times the outlet. The split fractions stay fixed,
  so scaled feeds keep their branch proportions.
- A network was reused after stream_splitting was toggled. The cache key
  now also requires getattr(self, '_synthesis_options', (False,)) ==
  (self.stream_splitting,) (_synthesis_options is set by T8b's fresh path).
- _stage_scales is read with getattr(..., {}) like _synthesis_options, for
  a facility cached before stream splitting existed.
- The class Notes say that the cache also keys on stream_splitting and
  that the splitters keep their fractions.

The _plan_sides cells collection of 5.6 was already in place since T3
(used pairs include sp.cells); removing it makes
test_splitting_with_avoid_recycle_never_repeats_a_pair fail (checked).

Tests (tests/test_hxn.py):
- simulate_strictly(sys): the strict simulation of simulate_HXN, factored
  out (same filters).
- test_cached_split_network[smith2005_ex18_2_split, rtB05_above_2h1c]:
  cache_network=True, all feeds +10 % then -10 %: the same System and
  units are reused; at the start of the network's convergence every
  stream of every split stream carries factor x its fresh flows (rtol
  1e-12; none of them is a tear stream, whose consumer runs before its
  producer in path order: there, as without splits, only the convergence
  brings the new flows); no mixer ran outside the convergence and every
  mixer ran inside it (a spy, which records because the facility's guard
  would swallow an assertion); splitter splits unchanged; every branch
  limit equals f x its whole-stream restore (branch_limits); every
  exchanger's Q is factor x its fresh Q and every outlet equals the
  original's (1e-12 of the total duty in H, 1e-9 K; measured at most
  4.3e-15, 2.4e-14 and 4.3e-12 K); feasible, EB < 1e-6 %. Then
  stream_splitting off gives a new unsplit network and on again a new
  split network with no unit in common. Before the fix: smith failed on
  the cache warning, rtB05 on the stale branch flows.
- test_point_load_enters_before_the_splitter, cached part: feeds +10 %,
  the cached network is reused, the splitter feed is the point load's
  equilibrium state at its inlet enthalpy (bit-identical T, H within
  1e-12 of the duty), both branch inlets at that T; a fresh synthesis at
  the same feeds agrees: same splitter feed T, fractions within rtol 1e-12
  (they differ by 1 ulp), every Q within 1e-12 of the duty (measured
  1.8e-17). Failed before the fix on the cache warning.
- test_move_flows_moves_flows_only: a splitter and two mixers (liquid
  outlet; rigorous, two-phase outlet), all flows +10 %, then only the
  vapor feed: split outlets are 0.3/0.7 of the feed at its T, mixer
  outlets are the summed flows at their own T and P, and Mixer._run
  (patched to raise) never runs.
- test_split_avoid_recycle_facility: r002 with avoid_recycle and
  stream_splitting reaches MER (80/450 kW) with a split below and no pair
  repeated, strict checker clean, feasible.
- test_repair_on_split_plan: DRIFT_CASE (review B's seed 172) on 0.5 K
  chords with _MAX_REFINE = _MAX_SPLIT_RETRY = 0: _repair shrinks a
  branch exchanger (hot fraction 0.822) from 222328 to 222189 kJ/hr,
  info['repaired'] records exactly _repair's changes, the realized branch
  exchanger has the repaired duty, the utilities are the repaired plan's
  (1e-12 of the total), status best_effort, feasible, strict checker
  clean.
The last two passed before this change (T3, T7 and T8b built what they
test); they are the task's acceptance tests at the facility.

Mutations (scratch t8c_mutation.sh), each caught: the cache key without
the options (2 failures), life_cycle[0] as the entry (2), no f on branch
limits (2), Mixer._run in the pre-copy (3), zip for splitters (3), zip
for mixers (3).

Deviation: the design names regression case 04 for
test_repair_on_split_plan, but case 04 (and cases 08 and 09, and rtB05)
has no round-0 violation even at tol_T = 0.5 to 10 K, so _repair never
runs there; DRIFT_CASE is the facility case whose split side needs it.

Observed (thermosteam, read-only): a TP flash of 50 Water/25 Ethanol
kmol/hr at 101325 Pa gives vapor fraction 0 at 355.1135 K although the
bubble and dew points are 354.46 and 363.30 K (0.049 at 354.6 K, 0.478
at 357 K). The pre-copy's TP flash only sets a starting phase split
(today's exchanger rule has the same exposure); the rigorous units
re-flash in the convergence.

Validation:
Full suite (pytest . --disable-numba=1 -q): 756 passed in 271.48s (0:04:31) (T8b: 751 passed).
R1 oracle (full): 93 records, sha256 6c98f043a59a2e5b, 111.8 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
R1 oracle (full): IDENTICAL to r1_full.json (stored sha256 6c98f043a59a2e5b)
(includes th/kemp_cache and th/r002_avoid_recycle)
R1 oracle (planner): 78 records, sha256 7307a56778e56268, 456.1 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268)
ast.parse(feature_version=(3, 12)) on both changed files: ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… tests

Review milestone C (HX and life-cycle diff T8a-T8c). Each finding was
checked against the code before acting; nine are fixed, one (pre-existing)
is left as a follow-up.

Facility (hensmith/_heat_exchanger_network.py):
- Cache key fallback (findings 1 and 3, one defect). The key read
  getattr(self, '_synthesis_options', (False,)), which the T8c body says
  lets a facility cached before stream splitting keep its cache. It could
  not: _stage_fractions predates stream splitting (ce17233), so such a
  facility passed the key with stream_splitting off, and the cached branch
  then raised AttributeError: 'StreamLifeCycle' object has no attribute
  'entry' (reproduced by the new test below; plot_pinch_diagram fails the
  same way through StreamLifeCycle.H_in). The default is now None: a
  facility with no recorded options synthesizes a new network. The T8c
  rationale for the getattr defaults was wrong. _stage_scales is read
  directly, and _synthesis_options is now set together with
  _stage_fractions and _stage_scales, so whenever it is present they are
  too. Objects the current code creates are unaffected (the fresh path
  always sets all three), so R1 is identical.
- save_stream_life_cycles_as_csv (finding 4). For a stream split at its
  inlet, row 0 (the first exchanger of branch 0) wrote the whole stream's
  H_in next to the branch's H_out (a fraction f of the flow), so the row
  was neither the stage's duty nor the stream's. Every row now carries its
  stage's own H_in and H_out, and a stream whose entry port is a Splitter
  first gets a row for its splitter chain at the whole stream's inlet
  enthalpy (H_in = H_out = lc.H_in, with T_in). Unsplit streams write the
  same rows as before (stages[0].H_in == lc.H_in exactly). The file is now
  written in a with block (it was never closed) with newline='', as the
  csv module requires (on Windows each row was followed by a blank line).
  The method is tested now, so its "pragma: no cover" is dropped.
- Class Notes (finding 6): the three paragraphs that the T8b/T8c edits
  left ragged are re-filled to the file's width. The first sentence now
  begins "Unless `stream_splitting`," so that the :func: role fits on a
  line. The meaning is unchanged.

Tests:
- test_cache_network_from_before_stream_splitting_synthesizes_again (new):
  a facility stripped of _synthesis_options, _stage_scales and each life
  cycle's entry/splits (what an older object lacks), with the feed 5 %
  larger, synthesizes a new network with no RuntimeWarning and matches a
  fresh synthesis. It failed before the fix with the AttributeError above.
- test_stream_life_cycles_csv (new): on smith2005_ex18_2 every row equals
  its stage's (ID, H_in, H_out, T_in on the first row, T_out on the last),
  with one Split_2_hs entry row. It failed before the fix (row
  HX_1_2_hs had the whole stream's H_in).
- test_move_flows_moves_flows_only (finding 7): the feed's T and P change
  before each pre-copy and both splitter outlets must take them. The
  two-phase mixer outlet must keep its PH-flash phase split, scaled, when
  every inlet grows by 10 %, and must equal a TP flash at its own T and P
  when only the vapor grows. The always-true isinstance check is removed.
  The reviewer's check 0 < vapor_fraction < 1 is not added: thermosteam's
  TP flash (read-only) gives all liquid at that outlet's 355.11 K, although
  its bubble and dew points are 354.46 and 363.30 K (the T8c observation),
  so after the re-flash both the outlet and any reference flash have vapor
  fraction 0. The reviewer's weak _move_flows (no T/P copy at the
  splitter, no flash at the mixer) now fails the test.
- test_split_network_registers_no_intermediate_streams (finding 8): the
  doctest system with stream_splitting must give the network it has without
  the flag bit for bit ([(ID, Q)] of new_HXs and of new_HX_utils, and
  refine_rounds; design R3).
- test_repair_on_split_plan (finding 9): a spy on _realize keeps the units
  of each plan's last realization. Every branch exchanger that _repair
  shrank must be the unit realized for its plan index, be in new_HXs, be a
  branch stage in its hot or cold stream's life cycle, and carry the
  repaired duty (1e-12 of the total). Before, any branch exchanger whose Q
  matched the value passed.
- test_cached_split_network (finding 10): the +/-10 % part also runs
  crude_fractionation_ph11c2, whose cold stream 1 is split into four
  branches below the pinch and again above it (two splitter chains, the
  second fed by the first's mixer). The stream_splitting toggle stays on
  the two small cases. This network has tear streams on the split stream:
  hot streams 2, 5 and 7 meet stream 1 both above and below the pinch, so
  the path places the above-pinch branch exchangers before Split_1_hs, and
  the splitter's outlets to them are tears. The stale-flow check now skips
  only what the path-order pre-copy cannot update before the convergence:
  the outlets of a tear's consumer and everything after them on that
  stream, found in flow order along lc.connections(). The same is true
  without splits. The test asserts that a chain element's outlet and a
  mixer-fed splitter are among the streams it checks.
- Duplication (finding 5): test_hxn_mer's _connection_path, a line-for-line
  copy of the facility's _network_path, is deleted, and
  _wired_split_network now calls _network_path (its heapq import is
  dropped). test_hxn's SPLIT_UNIT_IDS is built from
  test_hxn_mer._NETWORK_UNITS.

Not fixed (finding 2, pre-existing, also seen with stream_splitting off):
when the cached network's sys.converge() fails, _cost runs every unit once,
warns, and keeps the unconverged network if the outlet check (rtol 1e-3)
and the EB check pass. The reviewer measured this on
bagajewicz_ou_crude_unit_15_stream at x1.1 then x0.9: EB 2.7e-4 %, one
approach 19.9998 K, and, with splits, non-isothermal re-joins. Following
the reviewer, it is left as a follow-up. The minimal remedy is to treat a
failed convergence as a failed cache check (resynthesize); separately, the
slow contraction of the repeated-match loops under the cached path's fixed
H_lim needs a root cause. Related: the cached path's pre-copy moves flows
in path order, so the consumer of a tear starts the convergence from old
flows. Moving the flows along each stream's own connections, which are
acyclic, would avoid that.

Mutations (scratch reviewCfix_mutation.sh; each caught): the cache default
back to (False,) (the new cache test fails), no f on branch limits (all
three cached cases, crude included), splitters moved by zip (the three
cached cases and move_flows), mixers moved by zip (the same four), the
mixed CSV row (the CSV test). The reviewer's weak _move_flows is also
caught.

Validation:
Full suite (pytest . --disable-numba=1 -q): 759 passed in 307.50s (0:05:07)
(review C baseline at 701203a: 756 passed; +3: two new tests and the crude
parameter of test_cached_split_network).
R1 oracle (full): 93 records, sha256 6c98f043a59a2e5b, 122.1 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
R1 oracle (full): IDENTICAL to r1_full.json (stored sha256 6c98f043a59a2e5b)
R1 oracle (planner): 78 records, sha256 7307a56778e56268, 529.6 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268)
ast.parse(feature_version=(3, 12)) on the three changed files: ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SPLIT half of the MER corpus (test_hxn_mer) proves, by the pinch design
rules, that MER needs stream splitting, and so far only checked the default
facility (feasible, balanced, 'best_effort'). With the facility's new
stream_splitting option (milestones A-C) the synthesizer promises MER on
these problems; this adds the tests that hold it to that promise on the 25
constant-CP SPLIT problems (design 6.2; the 13 real-thermo ones follow in
T10 by extending the parametrization from SPLIT_CP to SPLIT):

- test_split_network_balanced_and_feasible: the strict split-aware checks
  G0-G10 (_split_network_problems) on _network(case, True).
- test_split_network_reaches_mer (_split_mer_problems): status 'mer'; the
  heating and cooling equal hensmith's targets within MER_TOL x total
  duty (1e-12 for constant CP) and the independent closed-form cascade
  within MER_TOL + its tolerance; splitters and mixers present; deviations,
  split_deviations, repaired, dropped and qmin_dropped all empty (every
  exchanger at its planned duty, every mixer at its planned state);
  min_approach >= T_min_app - APPROACH_TOL; no side best effort; every split
  side's method is split-<candidate>, with leak == preleak == 0, no small
  cell and no failed strategy; at most _SPLIT_MIX_CAP mixers per curved
  stream and side (vacuous for constant CP: the planner merges collinear
  grid knots, so none of the 25 cases has a curve with more than two
  knots, scratch t9b_curved_probe.py; it bites in T10).
- test_split_network_structure: >= 2 branches, one fraction each, summing to
  1 within 1e-12, every branch with an exchanger, each an HXprocess of the
  facility's new_HXs, and the split's splitters and mixer among its
  new_splitters and new_mixers; each life cycle holds exactly its stream's
  splits in flow order with every branch's stages and fractions; a must
  split (hot above, cold below) is isothermal; stream_HXs_dict is the life
  cycle.
- test_split_exact_dTmin_is_enthalpy_limited (smith2005_ex18_4_split): every
  branch exchanger has dT == T_min_app - 1e-6, stops at one of its enthalpy
  limits (within DUTY_RTOL of the stream duty), and its duty equals the
  planned duty of the facility's round-0 plan (rebuilt independently from
  _corpus_knots, IDs as _realize names them) within _DUTY_TOL; at least one
  of them has a terminal at exactly T_min_app (1e-9 K).
- test_no_split_network_with_splitting (4sp1_lee1970_dt10F,
  linnhoff_4stream): with the flag, the same exchanger IDs and bitwise the
  same duties as without, no splitter or mixer, the same refine rounds and
  repairs, and the checks pass.
- test_backstop_alone_reaches_mer: the vertical core alone (_SPLIT_RULES =
  (), _CORE_STRATEGIES = ('V',)) at the planner on the 25 cases' round-0
  knots plus the two constructed pre-leak problems of test_hxn_planner
  (NEAR_THRESHOLD, and nptel_t5_3 with CP2 = 6 - 1.58e-11): 'mer', every
  split side picks V with no leak, penalty <= preleak + 1e-12 scale,
  preleak > 0 exactly on the constructed problems, and every split side's
  cells (captured from _planner._plan_side) pass the independent
  _verify_side_cells from the pre-leaked root;
  test_backstop_alone_reaches_mer_in_the_facility does the same end to end
  on smith2005_ex18_4_split (variant 'V'): the checks above and G0-G10.

Every test passed on its first run: the behaviour was built by T3-T8c, and
no failure needed a fix in hensmith (nothing under hensmith/ changed). That
the tests can fail was shown with source mutations of hensmith, each caught
(scratch t9b_mutation.py/.out, t9b_mutation2.py/.out): no dT guard
(exact-dTmin), branch H_lim off by 1e-4 (reaches_mer, balanced, exact-dTmin,
backstop facility), every mixer reported as deviating (reaches_mer), never
isothermal (structure, balanced), stage fractions lost (structure,
balanced), splits in reverse flow order (structure, balanced, reaches_mer),
the flag changing the planner's Qmin (balanced, reaches_mer; at 1e10 also
test_no_split_network_with_splitting), no pre-leak (backstop, on the two
constructed problems).

Per-case times with stream splitting (facility synthesis and simulation,
_network(case, True)['time']; without the flag in parentheses; scratch
t9b_times.out):
  case                                       on   (off) units  splits  picks (side and rule)
  cornell_processdesign_four_stream_split 0.17s (1.77s)  5 HX  3 splits  above S:mincell, below S:nw-exact-desc
  nptel_t4_4_four_stream_dT20             0.04s (1.10s)  5 HX  1 splits  below S:partner
  nptel_t5_3_four_stream_split            0.03s (2.00s)  5 HX  2 splits  below S:nw-exact-desc
  smith2005_ex18_2_split                  0.02s (0.03s)  4 HX  1 splits  above S:nw-exact-desc
  smith2005_ex18_4_split                  0.01s (0.12s)  4 HX  1 splits  below S:demand
  smith2005_ex16_5_five_stream            0.05s (0.13s)  3 HX  1 splits  above S:nw-exact-desc
  smith2005_exr18_4                       0.06s (0.07s)  6 HX  1 splits  above S:partner
  smith2005_exr18_7_six_stream            0.06s (0.07s)  6 HX  1 splits  above S:partner
  7sp3_dt20F                              0.09s (0.26s)  9 HX  1 splits  above S:partner
  7sp4_dolan1990_dt10K                    0.09s (0.16s)  9 HX  1 splits  above S:partner
  8sp1_dt20F                              0.44s (0.06s)  8 HX  1 splits  above S:nw-rho-asc
  8sp1_sargent1978_dt10F                  0.05s (0.07s)  7 HX  1 splits  above S:demand
  nptel_t5_7_eight_stream_split           0.06s (1.82s)  9 HX  1 splits  below S:demand
  smith2005_exr18_5_nine_stream           0.20s (0.52s)  7 HX  1 splits  below S:partner
  crude_fractionation_ph11c2              0.32s (2.11s) 17 HX  2 splits  above S:partner, below S:nw-exact-desc
  bagajewicz_ou_crude_unit_15_stream      2.08s (2.16s) 28 HX  7 splits  above VT
  bjork_pettersson_15sp_ph8c7             0.59s (4.66s) 23 HX  2 splits  above S:partner, below S:demand
  fs_15sp_tkm                             0.61s (0.34s) 14 HX  1 splits  below S:partner
  cgm_balanced8                           0.46s (1.93s) 21 HX  1 splits  above S:partner
  cgm_balanced10                          1.02s (4.40s) 29 HX  2 splits  above S:partner, below S:demand
  cgm_unbalanced10                        0.95s (4.02s) 27 HX  2 splits  above S:partner, below S:demand
  luo_20sp_ph10c10                        3.01s (1.48s) 52 HX 34 splits  below V
  fs_22sp1                                0.13s (2.74s) 26 HX  1 splits  above S:partner
  fs_22sp_ph                              0.71s (2.80s) 16 HX  3 splits  above S:partner, below S:mincell
  grossmann_balanced12_r0                 0.63s (3.81s) 35 HX  3 splits  above S:partner, below S:demand
  facility, variant V: smith2005_ex18_4_split 0.01 s, mer

The facility is faster with the flag on the whole (11.9 s against 38.6 s):
the split sides skip the unsplit best-effort searches. The planner-level
backstop takes 0.4 s for the 25 cases.

Validation: tests/test_hxn_mer.py parses with ast feature_version (3, 12);
full suite 865 passed in 394.55s (0:06:34) (759 before: + 25 x 3 split tests, 1 exact-dTmin, 2
NO_SPLIT identity, 27 planner backstop, 1 facility backstop = +106);
the same tests took 289 s in the run before a system crash interrupted
this task (suite times on this machine vary between runs: 271-469 s at
milestone C).
R1 oracle --check, planner mode:
  R1 oracle (planner): 78 records, sha256 7307a56778e56268, 564.1 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
  R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268)
R1 oracle --check, full mode:
  R1 oracle (full): 93 records, sha256 6c98f043a59a2e5b, 138.3 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
  R1 oracle (full): IDENTICAL to r1_full.json (stored sha256 6c98f043a59a2e5b)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
T9b held the facility's stream_splitting option to its MER promise on the
25 constant-CP SPLIT problems. This extends the same tests to the 13
real-thermo ones, where the knots are chords of the true curves, so a
split plan reaches MER only if the refine loop closes every exact-state
violation (risk R-1) and every rigorous mixer lands on its planned state
(R-2, R-11); it also adds the design's identity and regression items
(design 6.2, 6.4, task T10):

- test_split_network_balanced_and_feasible, test_split_network_reaches_mer,
  test_split_network_structure and the planner-level
  test_backstop_alone_reaches_mer now run over the whole SPLIT list (38
  cases; SPLIT_CP is gone). On real thermo, _split_mer_problems holds the
  utilities to the targets and the reference within MER_TOL 1e-10 x total
  duty, asserts repaired == split_deviations == deviations == dropped ==
  [] (R-1: the refine rounds closed every violation, no _repair), and its
  mixer cap (_SPLIT_MIX_CAP per curved stream and side, the bound on the
  mixers' enthalpy residuals) now bites: every real-thermo split network
  has a mixer on a curved stream (13 of 13; vacuous on constant CP).
- test_split_exact_dTmin_is_enthalpy_limited also on
  rtB12_above_2h1c_cond_boil_below (no refine round, so round 0 is the
  realized plan).
- test_backstop_alone_reaches_mer_in_the_facility also on rtB12 and rtB07
  (BACKSTOP_FACILITY).
- test_no_split_network_with_splitting also on the 16 real-thermo NO_SPLIT
  cases, the only place their refined knots meet the flag: bitwise the
  same exchanger IDs and duties, refine rounds and repairs.
- test_long_vertical_chain_is_exact (new): V with _SPLIT_COARSEN = False on
  rtB07 above chains 276 elementary blocks (3 coarsened); every node passes
  (R) by direct evaluation, no leak, no missed knot, the last node is
  exactly the musts' duties, and the cells pass _verify_side_cells
  (test_hxn_planner.verify_core, now imported).
- tests/test_hxn_regression.py: the checks (i)-(v) move into
  check_network; test_hxn_regression_with_splitting runs all 10 CASES with
  stream_splitting=True through them; cases 04, 08 and 09 (SPLIT_CASES)
  must reach 'mer' with splits, every other case must equal the default
  network bit for bit (IDs, duties, refine rounds, repairs). synthesize()
  takes stream_splitting (default False). Module docstrings updated.

Every test passed on its first run apart from one assertion of the new
chain test itself: it expected 100x more elementary than coarsened blocks,
and the case has 276 against 3 (now >= 200 and <= 10). Nothing under
hensmith/ changed, and no tolerance was touched. No real-thermo case
needed investigation: no repaired, split_deviations or G5 problem anywhere.
That the tests can fail was shown with mutations (scratch t10_mutation.py,
t10_mutation_run1.out, t10_mutation2.out; control: 188 passed):
- no refine rounds (_MAX_REFINE = _MAX_SPLIT_RETRY = 0): reaches_mer fails
  on rtB07 (repaired);
- the test's mixer cap at 0: reaches_mer fails on all 13 real-thermo
  cases and the backstop facility on rtB12 and rtB07, on no constant-CP
  case;
- vertical-block end nodes off by 64 ulp: the long-chain test fails;
- no dT guard: 2 failures, exact-dTmin on rtB12 among them;
- planned duties 1 ulp up with the flag: the identity fails on 15 of the
  16 rtA cases and 6 of the 7 unsplit regression cases (rtA02 and case 02
  realize the same bits);
- Qmin x 1e10 with the flag: 68 failures (x 1e4 changes no network);
- no split candidates (no rule, no core strategy): 82 failures, among them
  regression cases 04, 08 and 09.

Measured (facility synthesis and simulation, _network(case, True)['time'];
without the flag in parentheses; scratch t10_probe.out, t10_probe_E.out):
  case                              on    (off)   units splits rr picks
  rtB01_above_3h2c_liquids          2.68s (9.32s)  6 HX  2      0  above LV, below S:partner
  rtB02_below_3c2h_liquids          1.01s (8.28s)  9 HX  2      0  above LVT, below S:partner
  rtB03_above_hot_vapors            0.28s (5.10s)  9 HX  4      0  above S:mincell, below S:demand
  rtB04_below_cold_boilers          0.75s (5.37s)  8 HX  1      0  below S:partner
  rtB05_above_2h1c                  0.05s (0.18s)  3 HX  1      0  above S:partner
  rtB06_below_2c1h                  0.03s (0.35s)  3 HX  1      0  below S:partner
  rtB07_above_with_cold_glide       1.48s (9.25s)  8 HX  2      1  above LVT, below S:demand
  rtB08_above_steam_creator         1.70s (12.43s) 7 HX  3      0  above S:mincell, below S:demand
  rtB09_below_with_cold_glide       0.11s (0.36s)  3 HX  1      0  below S:partner
  rtB10_11_streams_above            2.16s (14.00s) 11 HX 2      0  above LVT, below S:partner
  rtB11_12_streams_below            0.92s (6.34s)  9 HX  1      0  below S:partner
  rtB12_above_2h1c_cond_boil_below  0.06s (0.19s)  4 HX  1      0  above S:partner
  rtB13_above_3h2c_glide_below      0.33s (6.71s)  7 HX  2      0  above S:partner, below S:partner
  total                            11.6s (77.9s)
- refine rounds: 1 in total (rtB07, as the T5b spike predicted), retries
  0, no restore; repaired, split_deviations, deviations, dropped empty
  everywhere; min_approach >= T_min_app - 4.2e-10 K; at most 1 mixer per
  curved stream and side.
- backstop alone (V), facility: smith2005_ex18_4_split 0.01 s, rtB12
  0.03 s, rtB07 0.53 s (17 HX, 11 splits), all 'mer' with no refine round;
  planner: 13 real-thermo cases 0.4 s.
- NO_SPLIT real-thermo with the flag: 16 cases, 2.6 s of synthesis, all
  refine_rounds 0 and identical to the default.
- regression with the flag: 0.7 s for the 10 cases; 04 (6 -> 4 HX, 1
  split), 08 (13 -> 8, 3 splits; 4.0 s -> 0.20 s) and 09 (12 -> 8, 2
  splits; 3.3 s -> 0.18 s) reach 'mer' with no repair (without the flag
  08 and 09 repair 2 exchangers each); the 7 others are identical.
- the T10 tests add about 18 s (65 timed entries: 22 s in a targeted run
  that also built the default rtA networks).

Validation: both test files parse with ast feature_version (3, 12);
git diff --check clean; full suite
  947 passed in 356.92s (0:05:56)
(865 before: + 13 x 3 real-thermo split tests, 13 planner backstop, 1
exact-dTmin, 16 NO_SPLIT identity, 2 facility backstop, 1 long chain, 10
regression = +82), +108.6 s over the 248.35 s baseline, inside the +200 s
target, so no 6.6 lever was applied (T9b's run took 394.55 s: suite times
here vary between runs).
R1 oracle --check, planner mode:
  R1 oracle (planner): 78 records, sha256 7307a56778e56268, 502.6 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
  R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268)
R1 oracle --check, full mode:
  R1 oracle (full): 93 records, sha256 6c98f043a59a2e5b, 129.4 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
  R1 oracle (full): IDENTICAL to r1_full.json (stored sha256 6c98f043a59a2e5b)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ocument split MER margins

Review milestone D (tests diff T9a-T10) found only test-side gaps; no
library code changes.

Regression split cases (three findings, one gap): for cases 04, 08 and 09,
the only real-thermo split networks outside the corpus (case 08 splits the
same stream on both sides of the pinch), test_hxn_regression_with_splitting
checked only check_network (i)-(v), status 'mer' and that splits exist.
Check (ii) and the synthesizer's 'mer' both accept a utility gap of up to
1e-6 x total duty, and nothing ran the split-specific checks, so a network
whose mixer ended off its planned state, whose exchanger was repaired, or
whose split side leaked would pass whenever the lost duty stayed under
1e-6 x total. These cases are now held to the corpus standard: the strict
split-aware checks G0-G10 (_network_problems on _network_record), both
utilities within MER_TOL['real_thermo'] of the problem-table targets, and
the synthesis report checks of the corpus. Those report checks (status,
realized splits, repaired / dropped / qmin_dropped / split_deviations /
deviations empty, min approach, no best-effort side, every split side with
a candidate and free of leaks, pre-leaks, small cells and errors) are moved
verbatim out of _split_mer_problems into _split_report_problems(HXN,
T_min_app, candidate), which needs no reference and so serves both
modules; _split_mer_problems keeps the reference and mixer-cap checks and
calls it, so the corpus tests check exactly what they checked before.
Red check (scratch fixD_red.py): on each of the three networks, a fake
'repaired' or 'split_deviations' entry, a split-side leak, a mixer outlet
0.01 K off (04, 09) and utilities shifted by 1e-9 x total all pass the old
branch and fail the new one with the intended message; unmutated networks
pass (measured gaps at most 1.75e-14 x total, case 09 cooling).

MER_TOL docstring (two findings): the Tolerances bullet justified MER_TOL
only for NO_SPLIT networks ("about 100 times"), although
_split_mer_problems applies it to the 38 SPLIT networks synthesized with
stream_splitting=True. Extended without changing a value, with the maxima
from a run (scratch fixD_gaps.out): 5.3e-15 x total (fs_15sp_tkm) and
2.56e-12 (rtB05_above_2h1c), 187 and 39 times below MER_TOL; the flag-off
1e-9 "never beat" rule stays the rule for the default networks. The
test_split_network_reaches_mer entry now points at it and names
_split_report_problems.

Backstop patch (one finding): test_backstop_alone_reaches_mer repeated the
'V' variant's two setattr calls; it now plans under _variant('V'), the
patch the facility-level backstop test uses, so the two cannot drift.
monkeypatch remains for the _plan_side spy only.

Knot copy (one finding): _numeric_knots re-implemented the knots of
_planner._plan_numeric. With knots=1, _plan_numeric builds bit-identical
knots (linspace(a, b, 2) with its ends reassigned, float CP * (T - a),
float(dTmin)) and calls plan_network, which calls _planner._plan_side by
its module-global name (so the spy still records the sides); the
pre-leaked problems now plan through _plan_numeric(streams_from(rows), dT,
stream_splitting=True)['plan'] and the copy is deleted.

Validation: ast.parse(feature_version=(3, 12)) on both files; targeted
tests (regression, backstop, split MER) 141 passed; full suite
947 passed in 366.77s (0:06:06); tests/test_hxn_regression.py again
after a final docstring reflow: 20 passed. No library file changed, so
the R1 oracle runs in planner mode:

    R1 oracle (planner): 78 records, sha256 7307a56778e56268, 478.7 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
    R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stream splitting (stream_splitting=True) was implemented and tested in
milestones A-D, but the documentation still described hensmith as a
synthesizer that never splits streams. This commit documents the option
and marks every "does not split" statement as the default behavior.
There are no code changes and no behavior changes, apart from one property
docstring (see Docstrings).

Docstrings:
- The stream_splitting parameter now has the same text, byte for byte, in
  HeatExchangerNetwork, synthesize_network and plan_network (checked with
  diff).
- The module docstrings of hxn_synthesis, _planner and
  _heat_exchanger_network describe the option. The last of these was
  only a "Created on" stub.
- The _planner module docstring has a new "Stream splitting (optional)"
  section. Its "best effort", "series alternation" and "guarantees"
  statements now say what changes when the option is on.
- The _splitting module docstring now states Theorem M (the candidate
  portfolio always contains an MER candidate), with its proof sketch.
- The Notes of synthesize_network give the guarantee and the precise
  remaining gap: the knots are exact for constant CP, and on real
  thermodynamics the chords are closed by refine rounds plus the split
  retry, then repair.
- The info parameter says it is required with stream_splitting, and what
  'split' holds when no candidate is found.
- The class Notes of HeatExchangerNetwork have a "Stream splitting"
  paragraph covering the Split_/Mix_ IDs, new_splitters/new_mixers and
  synthesis_info['splits'].
- _pinch_cut and pinch_state now say that the synthesis does not cut
  streams at the pinch, even when it splits them.
- The description of StreamLifeCycle.H_in moved from the class
  Attributes section to the property's own docstring. With both, autodoc
  documented H_in twice and `sphinx-build -W` failed ("duplicate object
  description of hensmith.StreamLifeCycle.H_in"). This was latent since
  T8a added the property.

Doctests: two new examples. Their outputs were pasted from a run in which
the examples were first written without outputs and failed (scratch
t11_red.out):
- plan_network: a constant-CP case with two hot streams and one cold
  stream breaks the number rule above the pinch. Without the option the
  plan is 'best_effort'. With it the plan is 'mer', and the cold stream
  is split 0.5/0.5 above the pinch.
- HeatExchangerNetwork: case rtB05 (real thermodynamics) is simulated
  twice. Without the option it is 'best_effort'. With it the result is
  'mer', with Split_0_hs and Mix_0_hs, the exchangers HX_0_2_hs, HX_0_3_hs
  and HX_1_0_cs, and fractions (0.5201, 0.4799).

docs/source:
- index, API/heat_exchanger_network: a stream_splitting row in the
  options table; new_splitters and new_mixers rows; the cache key; the
  stream_HXs_dict order; the HXN_sys and synthesis_info rows.
- API/hxn_synthesis: an autoclass for StreamSplit (:no-members:, because
  its __slots__ duplicate the Attributes entries), and _splitting in the
  note on private modules.
- concepts: a new "Stream splitting" section on branches, realization,
  and the guarantee and its limits. The planner, report, convergence,
  validation, regression and guarantees text is qualified to match.
- tutorials 02, 03 and 04; contributing (the module list and what the
  tests check).

Tutorial 04 has a new "Splitting streams" section. Its code is a new
[start:splitting] region of docs/_demo_src/examples/ch04_configuring.py.
Its output is the new docs/source/_generated/ch04_splitting.txt, produced
by running only that script through pylock. The run synthesizes the 15 K
quickstart network, the first best-effort network of the sweep, both
ways:
- Without the option: best_effort, 8 exchangers, 6.684e+05 USD.
- With the option: mer, 5 exchangers, 5.893e+05 USD. The condenser
  (stream 3) is split below the pinch into branches of 0.5624 and 0.4376,
  one for each cold stream at the pinch.
After the run, `git diff --stat docs/source/_generated docs/source/_static`
showed no content change to the existing ch04 captures or PNGs. The three
existing captures showed as modified only because they were rewritten
with LF line endings, so they were checked out again.
docs/_demo_src/README.md lists the new capture.

README: one sentence on the option (DECISIONS Q7), and the best-effort
statement now says it is the default.

Validation:
- ast.parse(feature_version=(3, 12)) passes on every changed .py file.
- `sphinx-build -E -W -b html docs/source docs/build/html` exits 0 (build
  succeeded). The theme packages come from a --target directory, and the
  env is untouched.
- Full suite, run on the final tree: 947 passed in 322.70s (0:05:22).

R1 oracle --check:
R1 oracle (planner): 78 records, sha256 7307a56778e56268, 462.2 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268)
R1 oracle (full): 93 records, sha256 6c98f043a59a2e5b, 116.5 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
R1 oracle (full): IDENTICAL to r1_full.json (stored sha256 6c98f043a59a2e5b)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review milestone E. I reproduced each finding before acting on it
(scratch revE_repro.py and revE_mutations.py). All ten were real. The two
README findings name the same sentence, so one qualifier fixes both.

major: plot_pinch_diagram and save_stream_life_cycles_as_csv failed on
life cycles without an entry port, even with stream splitting off.
Cause: StreamLifeCycle.H_in unpacked self.entry, which only
get_life_cycle sets, and the CSV read life_cycle.entry.unit. At HEAD, a
life cycle whose life_cycle was assigned directly raised TypeError (plot)
and AttributeError (CSV). One from before stream splitting (no entry or
splits, e.g. unpickled) raised AttributeError in both. Fix: class
defaults entry = None and splits = (), so older instances resolve them.
H_in falls back to the first stage's H_in when there is no entry. This is
bit-identical: for an unsplit stream, entry is that same stage's port. The
CSV row for a splitter is written only when there is an entry. New test
test_life_cycles_without_entry_plot_and_write_csv: on the doctest system,
the plot texts and the CSV rows are identical with get_life_cycle's life
cycles, with life_cycle assigned directly, and with entry and splits
deleted.

minor: a facility from before stream splitting failed in _cost. It has no
stream_splitting attribute, and the cache key reads it. Fix: class
attribute stream_splitting = False. __init__ still sets it, so new
objects are unchanged. test_cache_network_from_before_stream_splitting_
synthesizes_again now also deletes HXN.stream_splitting and checks that
the network is synthesized again, with no warning and no splitter.

minor: Stage S threw away workable splits when a capacity branch would be
tiny. Cause: _cut_fractions gave a capacity branch the fraction load /
sum(loads) and ignored the capacity's CP slack. _split_items then merged
any branch below _SPLIT_MIN_FRACTION into its sibling. For a capacity
that undid the split, so every rule failed, and a core candidate carried
a branch of 1e-4 to 1e-9 of its stream. A capacity branch only needs
g c >= load. Fix: _pinch_split passes each capacity's CP and its own
fraction f of its parent to _cut_fractions. When some branch would fall
below the minimum (f g < _SPLIT_MIN_FRACTION), the new _raised_fractions
spreads the capacity's CP slack in closed form (water-filling):
g_k = max(g_min, lam x_k), with sum g = 1. Sort the loads ascending. The
raised branches are then the first t, and lam = (1 - t g_min) /
sum_{k>=t} x_k. The first t with lam x_t >= g_min is the answer: raising
branch t lowers lam exactly when lam x_t < g_min, so lam only falls and no
raised branch comes back above g_min. The unraised branches keep one CP
ratio, lam c (uniform, like the loads' shares). Every branch satisfies
g c >= x iff lam c >= 1. g_min is rounded up until f g_min >=
_SPLIT_MIN_FRACTION in floating point, which is the test _split_items
applies. When the slack cannot cover the raise (lam c < 1, or
n g_min > 1), or when every branch already reaches the minimum, the
fractions stay the loads' shares bit for bit. Demand branches are
unchanged (f = load / m). TINY_BRANCH and the review's case
(H0 170->150 CP 7, C1 120->175 CP 1.000001, C2 140->180 CP e, dT 10;
e = 1e-4, 1e-6 and 1e-9) used to end with every S rule failing and V or LV
picked with a branch of 1e-4 to 1e-9. They now pick S:partner with
fractions (0.999, 0.001). Tests: test_cut_fractions_spread_the_capacity_
slack (the closed form, f < 1, the rounding guard, and the no-slack and
all-above-minimum cases unchanged), test_stage_s_keeps_capacity_branches_
above_the_minimum (TINY_BRANCH and TINY_HOT_BRANCH: every rule except
'demand' splits, an S candidate is picked, the plan is 'mer' and exact,
and every fraction is >= 1e-3). test_pinch_split_screens_the_branched_root
now expects (0.999, 0.001) on TINY_BRANCH, and shows the merge on
TIGHT_TINY_BRANCH (CP 1.0001 on 1 and 1e-4: no slack, every rule fails).
Module docstring ('Stage S') and _pinch_split/_split_items docs updated.

minor (tests): no mutation tripped the G5 equilibrium sub-check on its
own, and the mutations matched only the tag. New mutation
mixer_outlet_off_equilibrium, run on rtB05 (real thermo; MUTATION_CASES):
the mixer outlet keeps its flows, P and H, with 2 % of its main chemical
vaporized. It gives exactly 'G5 Mix_0_hs: outlet at 366.96 K, not at
equilibrium at its enthalpy (377.74 K)' on both the wired and the
facility network. Every mutation now returns (tag, message fragment), and
the test asserts that the defect's own message appears. I surveyed each
mutation's problems on both sources to pick the fragments. The module
docstring now says each defect is applied to both the hand-wired network
and a fresh facility network (fifteen defects).

minor (docs): README qualifies the MER claim ("not guaranteed with
avoid_recycle; see the documentation for the limits with real
thermodynamics"), in one sentence (DECISIONS Q7). plan_network's
stream_splitting entry now says what it returns (plan.splits, plan.paths,
the exchangers' fractions, info['sides'][side]['split']) instead of
describing realization. _splitting.py lines 1894 and 2130 are re-wrapped
to 79 columns. Two test comments said smith2005_ex18_2 splits a cold
stream; it splits its hot stream (stream 2).

Validation:
- full suite: 953 passed in 373.29 s (947 + 6 new items)
- py3.12 ast.parse on every changed .py file: ok
- docs/_demo_src/examples/ch04_configuring.py re-run: generated outputs
  unchanged, including ch04_splitting.txt (line-ending churn reverted)
- R1 oracle --check (planner and full, since hxn_synthesis.py and
  _heat_exchanger_network.py changed):
R1 oracle (planner): 78 records, sha256 7307a56778e56268, 543.4 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268)
R1 oracle (full): 93 records, sha256 6c98f043a59a2e5b, 139.3 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
R1 oracle (full): IDENTICAL to r1_full.json (stored sha256 6c98f043a59a2e5b)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tion

T12 final validation. Review E's facility fuzz (scratch reviewE_fuzz.py,
pinch/odd/dbl/rand x 13, seed 0) was run with stream_splitting=True on
4acb8db. All 52 problems reached 'mer', but 5 of the 13 'odd' problems
(odd CPs including 1e-4 and 1e-3) failed the strict check G4 'fraction'
(test_hxn_mer._split_network_problems). There were two causes.

1. Stage S dropped demand branches it could have raised (odd 1). H1
(CP 1) fits no cold stream at the pinch: C3 (CP 0.999999) is short by
1e-6, so H1 needs a branch of 1e-6 to 1e-3 of its CP for C3 (CP 1e-3)
or C1 (CP 1e-4). Every cut transport gave that branch less than
_SPLIT_MIN_FRACTION: 'demand' 0.000999, 'nw-exact-desc' 1e-6,
'mincell' 1e-4. _cut_fractions gave a demand its loads' shares, so
_split_items merged the sliver into its sibling. That undid the split,
and every S rule ended 'no split'. The side fell back to a core
candidate (LV) that split H1 0.9999 / 1e-4. C3's CP has room for a load
of 1e-3, so the realizable split 0.999 / 0.001 existed. Review E raised
capacity branches from their parent's CP slack, but it left demand
branches unchanged. Fix: _cut_fractions takes each demand's own
fraction (dem, passed by _pinch_split). It raises a demand branch below
the minimum by the same water-filling as a capacity: g_k = max(g_min,
lam x_k), sum g = 1, with lam falling as branches are raised (proof in
_water_fill, now shared with _raised_fractions). g_min is rounded up as
before (_min_share). The raise is kept only if every capacity that gets
more load still has sum load <= c (_fits). The demands go in order, and
each uses only the room the earlier ones left. Capacity shares are then
taken from the new loads, and the capacity raise runs on those. An item
the slack cannot raise keeps the loads' shares bit for bit, and the
capacity path does the same arithmetic as before. The exact screen of
_pinch_split still decides every split. On odd 1, 'demand' and
'nw-exact-desc' now split H1 0.999 / 0.001 and the side picks an S
candidate.

2. G4 compared a measured flow ratio with the minimum at zero tolerance
(odd 5, 7, 8, 10). Stage S planned the split (0.999, 0.001), exactly at
_SPLIT_MIN_FRACTION (review E's rounding). The splitter's ratio is
0.999/fsum(fractions), and thermosteam's split_to makes the second
outlet as feed - first. The branch's ratio (1 - r) and its flows
therefore come out 1.3e-16 below 0.001 (0.00099999999999987),
cancellation relative to the feed. No splitter chain can promise
f >= 1e-3 exactly for a planned fraction at 1e-3: even a branch taken
by multiplication is off by an ulp. The checker already accepts the
realized ratios within FLOW_RTOL of the plan. Fix (test only): G4
applies the 1e-3 minimum, exactly, to the split's planned fraction. It
also requires the measured fraction to equal the planned one within
FLOW_RTOL, which it did not check directly before. The threshold is
unchanged. The module docstring's G4 says so, and the fifteen mutation
tests still find their defects.

Tests (written first; each failed for the cause above):
test_cut_fractions_raise_a_demand_branch_into_capacity_room (bit for
bit without room or dem; g_min for a fraction .5; two demands sharing
one capacity's room),
test_stage_s_raises_demand_branches_to_the_minimum (TINY_DEMAND_BRANCH
= odd 1: the rules split H1 0.999 / 0.001, and the plan is 'mer' with an
S candidate and every fraction >= the minimum),
test_split_branches_keep_the_minimum_fraction[tiny_demand_branch,
complement_at_the_minimum] (facility: strict checks and the synthesis
report clean, every planned fraction >= the minimum, feasible).

Robustness beyond the corpus (final tree):
- facility fuzz, seed 0, stream_splitting=True: pinch 13, odd 13, dbl 13,
  rand 13: 52 'mer', 52 split, 0 with problems (was 5, all 'odd').
- extra seed 1 (odd 20, dbl 20): 40 'mer'. Two 'odd' problems carry a
  branch below 1e-3 (G4 fraction), and physics forces it. In odd 7, H1
  (CP 1.000001) must shed >= 1e-6 of CP to a CP-0.001 capacity at the
  pinch, so its branch is <= 0.001/1.000001 < 1e-3. In odd 17 below,
  the same holds for C1 (CP 1.000001) against H2 (CP 0.001). The design
  accepts such branches from elementary vertical blocks and penalises
  them in the selection key (design section 0, 'min branch fraction');
  every other check is clean. Not a defect.
- avoid_recycle probe (scratch t12_ar.py, 30 pinch problems): no crash,
  no problem from the strict checks (G0-G10 incl. G7 approach), no
  repeated pair, nothing repaired/dropped/deviating; statuses 'mer'
  11, 'best_effort' 19 (23 split); MER is not promised with
  avoid_recycle.

Validation:
- full suite (CI invocation, --durations=30): 957 passed in 384.68 s
  (953 + 4 new items; baseline 467 in 248.35 s: +136.3 s, within the
  +200 s target, so no 6.6 lever). The 30 slowest entries take 189.2 s:
  test_hxn_mer 16 (105.5 s, top: network_balanced_and_feasible
  rtB10 14.3 s, rtB08 11.5 s, rtB01 9.6 s), test_hxn 8 (49.4 s,
  top: shrink_with_fractions_is_monotone[glide] 10.7 s,
  repair_on_split_plan 8.1 s), test_hxn_planner 5 (30.5 s, top:
  core_on_near_flat_pieces_reaches_mer 10.6 s), test_hxn_regression
  1 (3.8 s). The first T12 run on 4acb8db: 953 passed in 385.91 s.
- py3.12 ast.parse on every .py file changed since f914c4c: 9 files ok.
- R1 oracle --check, planner mode under PYTHONHASHSEED=0 and =1, full
  mode:
R1 oracle (planner): 78 records, sha256 7307a56778e56268, 534.3 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=0
R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268)
R1 oracle (planner): 78 records, sha256 7307a56778e56268, 534.9 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=1
R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268)
R1 oracle (full): 93 records, sha256 6c98f043a59a2e5b, 134.1 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset
R1 oracle (full): IDENTICAL to r1_full.json (stored sha256 6c98f043a59a2e5b)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Read the Docs build 35005409 of PR #2 failed under -W on two warnings,
"duplicate object description of hensmith.StreamLifeCycle.splits" (and
.entry). The cause is autodoc_default_options in docs/source/conf.py,
which listed 'undoc-members': False. Sphinx copies every key present in
autodoc_default_options into the directive options and converts a flag
option with bool_option, which returns True whatever the value, so the
key turned undocumented members ON. That had no effect until 4acb8db
(review E) gave StreamLifeCycle the class defaults splits = () and
entry = None: the class's autoclass (the only one with members; the
others use :no-members:) then documented them as undocumented members,
next to the entries napoleon writes for them from the class's Attributes
section, and each object was described twice.

Delete the key, so that undocumented members are really left out, with a
comment saying why. StreamLifeCycle has no other public member without a
docstring, so nothing else leaves the API pages.

Validation, built as on Read the Docs (sphinx 9.1.0, pydata-sphinx-theme
0.21.0, sphinx-design 0.7.0, sphinx-copybutton 0.5.2; python -m sphinx
-T -E -W --keep-going -b html, intersphinx online):
- sphinx's _process_documenter_options gives undoc_members True for
  {'undoc-members': False} and None without the key;
- 6d4776f (git archive): "build finished with problems, 2 warnings",
  the two duplicates; with this change: "build succeeded", 0 warnings;
- same 17 HTML pages and the same 65 objects.inv names before and after.
  Object signatures differ only on API/hxn_synthesis.html, where the
  undocumented "splits = ()" and "entry = None" are gone (42 -> 40) and
  StreamLifeCycle.entry and .splits are each described once, from the
  Attributes section; their inventory entries now link to those
  descriptions instead of the duplicates' anchors (#id5, #id0), and
  genindex lists each once. No other text on any page changed;
- full test suite: 957 passed in 325.91s (0:05:25).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI (ubuntu, Python 3.12 and 3.13) failed test_move_flows_moves_flows_only:
after `_move_flows` with every inlet of a rigorous mixer scaled by 1.1, the
two-phase outlet came out all liquid ([[0, 0], [55, 27.5]]) instead of its
vapor/liquid split scaled by 1.1. Locally (Windows) it passed.

Cause: for a multiphase outlet, `_move_flows` scaled the total flow (the
F_mol setter multiplies every phase by F_new/F_old) and kept that split
only if `s_out.mol.sparse_equal(mol)`, i.e. only if the scaled phases summed
to the inlet flows bit for bit (SparseVector.sparse_equal compares the float
dicts exactly); otherwise it replaced the flows and flashed at the outlet's
own T and P. Whether the sums match depends on the last bits of the PH flash
that made the split. CI's split, rebuilt exactly from the DESIRED values it
printed (each has a single preimage under x1.1), is a few ulps from the
Windows one with the same totals (50 and 25): 1.1 x its phases sums to 55.0
water and 27.500000000000004 ethanol against inlets of 55.00000000000001 and
27.5, so the check failed and the code flashed. Replaying the old code on
CI's split gives CI's ACTUAL bit for bit, and on the local split CI's
DESIRED. The flash is wrong there: thermosteam's TP flash at the PH flash's
own 355.11 K and 101325 Pa returns V = 0 (also for a fresh stream), although
the bubble and dew points are 354.46 and 363.30 K and the PH flash gave
V = 0.195 (a thermosteam issue, left to thermosteam). The old pre-copy was
fragile on Windows too: 65 of 301 common factors in [0.5, 2] and 17 of 41
one-ulp moves of the split left the outlet all liquid (126 of 301 factors on
CI's split). The exchanger branch (master's original pre-copy) had the same
check and flash.

Fix: `_move_flows` moves flows only; it compares no float for equality and
flashes nothing (as its docstring already promised). A multiphase outlet
keeps its phase split by a rule that needs no detection
(`_move_phase_flows`): each chemical that it carries keeps the fraction of
its flow in each phase (its phase flows are scaled by its new total over its
old one), so a common factor on every inlet scales every phase, to rounding;
a chemical that it does not carry enters in the phases in which it arrives
(a phase the outlet lacks is added with thermosteam's own `phases` setter;
`imol.mix_from` reads the phase list before expanding it and fails on a
genuinely new phase). The per-chemical flows match the inlets to a few ulps,
the phase flows stay non-negative and T and P are untouched, which is all
the cached path needs: the network's convergence re-runs every rigorous unit
from there. Single-phase outlets are unchanged. The rule serves mixers and
every other unit (exchangers) alike.

Tests: test_move_flows_moves_flows_only now sets CI's exact split on the
two-phase outlet (so CI's condition reproduces on any platform), forbids
Mixer._run and VLE.__call__, scales by 8 common factors, and checks a
vapor-only change and methanol arriving in the liquid against the
per-chemical rule (replacing the expectation of a TP flash). The new
test_move_flows_keeps_an_exchangers_phase_split covers a rigorous
HXutility(V=0.4) outlet: 3 factors, then more water with methanol arriving
as a new solid phase. Both fail on the old code ("VLE was run") locally and
on CI's pinned stack.

Validation:
- move_flows, cached-network, served-streams and HXN_sys tests: 14 passed
  locally (py3.14) and on CI's pinned stack (python 3.13.9, numpy 2.5.3,
  scipy 1.18.1, fluids 1.3.1, chemicals 1.5.2, thermo 0.6.1, numba 0.68.0,
  thermosteam HEAD 909df06c);
- full suite, CI invocation: 958 passed locally (315.7 s) and 958 passed on
  CI's pinned stack with python 3.13.9 (306.0 s);
- sweep with the fix: the split is kept for 301/301 factors (local and CI
  splits) and 41/41 one-ulp splits, within 4.5e-16;
- R1 oracle, full mode: IDENTICAL (sha256 6c98f043a59a2e5b), so default-off
  results are bit for bit unchanged;
- ast.parse(feature_version=(3, 12)) on both changed files.
Linux and Python 3.12 are not available locally; the push re-runs CI there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@read-the-docs-community

Copy link
Copy Markdown

@sarangbhagwat
sarangbhagwat merged commit 95e9362 into master Oct 8, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant