( match: MathJSON, replace: MathJSON, innerA: MathJSON )
| 150 | // two products into one commutative `Multiply(__a, _x, …)`, and the matcher |
| 151 | // cannot synthesize an EMPTY `__a` among the flattened factors (it fires only |
| 152 | // when `__a` is explicitly present, i.e. the already-scaled case). That |
| 153 | // regresses the unscaled integer-RHS case (`x·eˣ = 3`). For product inners we |
| 154 | // therefore keep the unscaled shape `Add(A(_x), __b)` (no leading scale |
| 155 | // wildcard). |
| 156 | // |
| 157 | // This no longer costs the rational-RHS case: `clearDenominators` (solve.ts) |
| 158 | // now skips exact numeric-literal denominators, so a rational RHS like |
| 159 | // `x·eˣ = −1/10` is NOT rescaled to `10·x·eˣ + 1` and reaches the templates |
| 160 | // as `Add(Multiply(_x, Exp(_x)), 1/10)`, matching this unscaled product-inner |
| 161 | // shape (the `__b` wildcard + `useVariations` absorbs the rational offset). |
| 162 | const isProduct = Array.isArray(innerA) && innerA[0] === 'Multiply'; |
| 163 | const negOffset: MathJSON = isProduct |
| 164 | ? ['Negate', '__b'] |
| 165 | : ['Negate', ['Divide', '__b', '__a']]; |
| 166 | const outer = (m as MathJSON[]).map((part, i) => |
| 167 | i === slot ? negOffset : part |
| 168 | ); |
| 169 | return { |
| 170 | match: isProduct |
| 171 | ? ['Add', innerA, '__b'] |
| 172 | : ['Add', ['Multiply', '__a', innerA], '__b'], |
| 173 | replace: substituteSymbol(outer, unknown, '_x'), |
| 174 | innerA, |
| 175 | }; |
| 176 | } |
| 177 | |
| 178 | // --------------------------------------------------------------------------- |
| 179 | // End-to-end self-test |
| 180 | // --------------------------------------------------------------------------- |
| 181 | |
| 182 | /** Probe values for the solve self-test (positive bias: many inner functions |
| 183 | * — `Ln`, `Sqrt` — are real only for positive arguments). The negative |
| 184 | * probes exercise templates that only validate for x0 < −1, e.g. the W₋₁ |
| 185 | * branch seed ed7dac (`A(x0) = x0·e^{x0} ∈ (−1/e, 0)` where W₋₁ inverts); |
| 186 | * probes whose image is non-real for a given seed are skipped structurally. */ |
| 187 | const SOLVE_SELFTEST_PROBES = [0.5, 1.5, 2.5, 0.7, 3.25, -2, -1.5, -3.25]; |
| 188 | |
| 189 | /** The no-capture filter (mirrors `solve.ts`'s `filter` and the loader copy): |
| 190 | * no wildcard other than `_x` may capture `_x`. */ |
| 191 | function noCaptureFilter(sub: Record<string, { has(s: string): boolean }>): boolean { |
| 192 | for (const [k, v] of Object.entries(sub)) |
| 193 | if (k !== '_x' && k !== 'x' && v.has('_x')) return false; |
| 194 | return true; |
| 195 | } |
| 196 | |
| 197 | /** Push the template (in isolation) to a stock engine's `solveRules` and |
| 198 | * solve a concrete instance `A(x) = A(x0)`; succeeds when a returned, |
| 199 | * `validateRoots`-approved root equals the chosen probe `x0`. */ |
no test coverage detected