rollsig · working notes

rollsig — Working Notes

A running record of the math and the build, in one place, extended chapter by chapter — last updated 28 July 2026
Chapter 1

Path Signatures, Worked From a Live Example

1.1 The path

Everything below refers to one concrete object: a 10-point, 2-dimensional random walk (cumulative sum of Gaussian steps, seed 0), read as a piecewise-linear path X:[0,T]2.

X₀ Xᴛ The path (solid), the chord from X₀ to Xᴛ (dashed), and the region between them — the geometric picture behind the Lévy area in §1.4.
kx₁x₂
01.76410.4002
12.74282.6411
24.61031.6638
35.56041.5124
45.45721.9230
55.60133.3773
66.36233.4990
76.80623.8326
88.30023.6275
98.61332.7734

1.2 What a signature is

For a word i1im over the alphabet {1,2} (one letter per path dimension), the corresponding signature entry is an iterated integral:

Si1im = 0<t1<<tm<T dXt1i1 dXtmim

Depth-k truncation keeps every word up to length k. For a 2-dimensional path that's 2+4+8=14 numbers at depth 3 — exactly what iisignature.sig(path, 3) returned. The empty word gets coefficient 1 always — it's the identity element, which is why RoughPy prints its signature starting with 1().

1.3 Level 1 — net displacement

Si = 0T dXti = XTi X0i
✓ verified numerically
coordinate 1coordinate 2
Xᴛ − X₀6.8492572.373225
S¹ (from iisignature.sig)6.8492572.373225

1.4 Level 2 — what it does and doesn't see

Split into symmetric and antisymmetric parts. The symmetric (diagonal) part turns out to be exactly determined by level 1 — a pure calculus identity, true for any 1-d path regardless of how much it oscillates:

Sii = 12 (Si)2
✓ verified numerically
value
½(S¹)²23.456160
S¹¹ (diagonal, from signature)23.456160
The diagonal level-2 term of the raw path is provably redundant with level 1 — it carries zero additional information, at any sampling resolution. It does not, by itself, capture realized variance. Getting quadratic variation out of signatures needs the mechanism in §1.5.

The antisymmetric part is the Lévy area — signed area swept between the path and the chord shown in §1.1 — and it's genuinely new information: it depends on the order in which the two coordinates moved, which the level-1 and diagonal level-2 terms cannot see.

2×Lévy area = S12 S21 = 0.955715.2991 = −14.3434

1.5 Lead-lag: how quadratic variation actually shows up

The Gyurkó–Lyons construction pairs a coordinate with a one-tick-delayed copy of itself, doubling the resolution: X^ (lead) steps to the next value first; Xˇ (lag) holds, then catches up. On this 2D (lead, lag) path, the antisymmetric cross-term is an exact identity, not an approximation:

Slead,lag Slag,lead = k (Δxk)2
✓ verified numerically — exact to 12 significant figures
value
realized ∑(Δx₁)²8.48624822212359
S(lead,lag) − S(lag,lead)8.486248222123592

This is the mechanism behind the claim that lead-lag preprocessing recovers quadratic variation — and it's exact, not asymptotic, which is a much stronger thing to be able to say in an interview than "signatures relate to volatility."

1.6 The ring structure: Chen's identity

The signature lives in the tensor algebra T((V))=m=0Vm — the free associative algebra on V=2. Concatenating path segments is the ring product of their signatures:

S(XY) = S(X)S(Y)
✓ verified numerically — iisignature.sigcombine

Split the path at index 5 into two halves, compute each signature separately, combine them via the tensor product — the result matches the whole-path signature to 8.8×10⁻⁷ (floating-point noise). This is the identity that makes signatures grouplike: they form a group under this product (identity = the level-0 term, inverse = the signature of the time-reversed path). It's exactly what a streaming/rolling-window update needs — drop the oldest segment by left-multiplying its group inverse, append the new one by right-multiplying — O(1) per tick in the steady state, instead of recomputing the whole window (built in Part III).

1.7 The dual structure: shuffle identity

The ordinary scalar product of two signature entries equals a sum over interleavings (shuffles) of the two words — the commutative Hopf algebra dual to the concatenation product above:

SuSv = wuv Sw
✓ verified numerically
identityLHSRHS
S¹·S² = S¹²+S²¹16.25483116.254831
(S¹)² = 2·S¹¹46.91231946.912319

This is the correctness oracle for any signature implementation, including the pure-numpy reference this package carries as a test oracle — a much stronger test than "looks about right," and it's a check that has to hold on every random path, not just this one. It's exactly what tests/test_backend.py in Chapter 2 turns into an automated regression test.

1.8 Log-signature — not yet computed

Because a signature is grouplike, its logarithm (via the tensor algebra's own exp/log) is primitive, and primitives of this Hopf algebra are exactly the free Lie algebra on V:

logS(X) Lie(V) T((V))

This is PBW-theorem territory: the Lyndon-word basis of the free Lie algebra gives a far smaller, information-lossless basis than the full tensor words. Computed in Chapter 4, once the transformer and preprocessing pipeline below existed to build it on top of.

1.9 Backend spike: iisignature vs RoughPy

iisignature 0.24RoughPy 0.3.0
cp314 wheel on PyPInone — source buildyes, prebuilt
build needed manual fixyes (numpy + --no-build-isolation)no
maintenancelow (last dep. update 0.24)active, Lyons-lab
APIsimple, flat arraysricher, more ceremony
agreement with other backendmax abs diff 4.6×10⁻⁵ at the time (see §2.3 — this was a bug in the wrapper, not the backend)

Both computed the same signature. RoughPy became the default given wheel availability and maintenance status; iisignature stays available behind the same narrow interface as a cross-check / reference during testing.

Chapter 2

Building rollsig v0.1

2.1 Package skeleton & the narrow backend interface

src/rollsig/_backend.py is the only file that imports RoughPy or iisignature directly. Everything else calls one function:

signature(path: np.ndarray, depth: int, backend: Backend = "roughpy") -> np.ndarray

which returns a flat array of levels 1..depth in lexicographic word order — the same layout iisignature.sig already uses, so both backends are drop-in interchangeable and every identity from Chapter 1 works unchanged as a test on either one. The point of isolating this in one narrow file: the moment a better backend appears (or RoughPy's API shifts again), only _backend.py changes — not the transformer, not the tests.

2.2 SignatureTransformer: causal rolling windows

src/rollsig/transformer.py is a normal sklearn fit/transform estimator. The entire causality guarantee comes down to one line in transform:

for t in range(n_samples):
    start = max(0, t + 1 - self.window)
    out[t] = signature(X[start : t + 1], self.depth, backend=self.backend)

Row t is built only from X[start : t+1] — data up to and including t, never beyond it. This loops per-timestep and recomputes each window's signature from scratch (O(window) per tick) rather than vectorizing or reusing the previous window's result — deliberate for v0.1: the constant-per-tick version is exactly Chen's identity from §1.6 (drop the departing segment via its group inverse, append the new one), and that's scoped as its own later chapter, not smuggled in early.

The proof that this is actually causal isn't a code-review argument, it's a test: test_causal_alignment in tests/test_transformer.py fits the transformer on a truncated prefix series[:t+1] and asserts the last row matches the row at index t from fitting on the full series. If any future change introduced lookahead, this test would catch it.

2.3 Two RoughPy bugs found while wiring it up

Both are silent — no exception, no warning, just a wrong number — which is the dangerous kind.

Bug 1 · precision truncated by string conversion

RoughPy's Scalar doesn't support float() directly. The obvious workaround, going through its pretty-printed string, quietly rounds to display precision:

float(str(sig[tensor_key]))   #  6.849256... becomes 6.84926
sig[tensor_key].to_float()    #  full float64 precision

This alone explained the entire ~4.6×10⁻⁵ "backend disagreement" reported in §1.9 — it wasn't RoughPy vs iisignature disagreeing, it was str() lying.

Bug 2 · ctx= mis-indexes repeated-letter words

Building a TensorKey from a shared context object gives the wrong value for words like (1,1) — it silently returns a different entry's value, with a correctly-labelled repr that makes the bug easy to miss on a quick print:

rp.TensorKey([1, 1], ctx=ctx)          # prints "(1,1)" but reads back S² 's value
rp.TensorKey([1, 1], width=2, depth=3) # correct, and doesn't itself warn

Caught only because §1.4's diagonal identity (S¹¹ = ½(S¹)²) failed in the test suite — another point for treating the algebraic identities as tests rather than assertions.

2.4 Where it stands

status
testsinitial core coverage; extended in Chapter 3
backend agreement1×10⁻⁸ after the fixes — true float64 precision, not a coincidence
PRv0.1-signature-transformer-skeleton → PR #25, open
still open at this pointhypothesis property tests (Chapter 4 adds the first one), log-signatures (Chapter 4), CI, and the streaming engine (Part III)
Chapter 3

Making a Path Financial

3.1 The preprocessing pipeline

A raw price path is not always the path we want the signature to see. The transformer now has four explicit options: basepoint, time_augmentation, lead_lag_transform, and rescale. They are applied separately inside every rolling window, so row t still uses no data after t.

window = X[start : t + 1]
if basepoint:         window = [(0,...,0)] + window
if time_augmentation: window = append_local_time(window)
if lead_lag_transform: window = lead_lag(window)
signature(window)

Basepoint prepends the origin. Ordinary signatures see increments, so they are translation invariant: without a basepoint, the paths [100, 101, 99] and [0, 1, -1] look identical. Including the origin adds the journey from zero to the first observation when that initial level is meaningful.

Time augmentation appends a local clock running from 0 to 1. It gives the path a monotone coordinate, so the signature can encode how movements sit within a rolling window rather than seeing only their projection onto price space. The clock is local to the window on purpose: its construction needs only the number of already-observed points, not future timestamps. A future timestamp-aware variant can preserve irregular real elapsed time instead.

Design choice: these are booleans rather than a hidden finance preset. The benchmark should be able to say exactly which path representation helped, rather than quietly combining several choices into one opaque feature set.

3.2 Lead-lag and quadratic variation

For a one-dimensional sampled path x0, x1, x2, ..., the lead-lag transform produces the two-dimensional staircase

(x0, x0), (x1, x0), (x1, x1), (x2, x1), (x2, x2), ...

Each price move becomes a horizontal step and then a vertical step. The signed second-level cross terms measure the small rectangles between those steps. Their antisymmetric difference is the area, while their symmetric combination is controlled by the endpoint. Together, they recover the sum of squared increments — the discrete quadratic variation that matters for realised volatility.

This is the central finance reason to include the transform: a level-2 signature of a one-dimensional path cannot see its internal variation beyond its endpoint, but the two-dimensional lead-lag path can. The transformer uses lead_lag_transform=True rather than silently imposing it because the embedding doubles the channel count and feature growth is exponential in signature depth.

3.3 A NumPy reference oracle

The production default remains RoughPy. A small backend="numpy" implementation now exists only to make the algebra executable in tests. For each increment dx, a straight line has signature

exp(dx)=1+dx+dx22!+

The reference multiplies those truncated tensor exponentials one increment at a time. This is Chen's identity as an algorithm: at every level k, sum the tensor products of the old level i and the increment's level k-i. It is intentionally slower than RoughPy, but has two payoffs: it does not depend on a backend API, and it can be checked independently against RoughPy whenever that optional integration is installed.

There is a useful separation of responsibilities here. The NumPy code is not a competing production engine; it is the simple thing we trust enough to catch wrapper mistakes. RoughPy is the fast thing we use for real work.

3.4 What is now guaranteed

✓ 18 passed, 2 optional-backend tests skipped

The current suite verifies causal alignment, output shape and names, parameter validation, basepoint/time/lead-lag construction, per-level factorial rescaling, the level-1 and diagonal level-2 formulae, shuffle identities, and Chen's identity for the independent reference implementation. The two skipped tests need optional external backends (RoughPy and iisignature); they run as cross-backend agreement checks when those packages are available.

The next learning step is log-signatures: why taking the tensor logarithm removes redundant shuffle coordinates and leaves a free-Lie-algebra basis. Chapter 4 does exactly that, and adds the first hypothesis-based property test along the way. After that, the streaming update built from the same Chen product and group inverse is next (Part III).

Chapter 4

Log-Signatures: Compressing the Redundancy

4.1 The redundancy problem, quantified

§1.7's shuffle identity said the tensor coordinates of a signature are not independent — e.g. S¹·S² is fully determined by S¹²+S²¹. Chapter 1 used that as a correctness check. Read the other way, it's a compression opportunity: the depth-k signature of a d-dimensional path has dk coordinates at level k, but the shuffle relations mean far fewer than that are actually free.

The exact count of free coordinates is given by Witt's formula for the dimension of the free Lie algebra Lie(V) on d=dimV generators:

dimLien(V) = 1n en μ(e) dn/e

(Möbius function μ, summed over divisors of the level n; implemented as n_log_features in _backend.py.) For the 2-dimensional path this whole notes page has been using:

depthtensor coords — n_features(2,depth)free coords — n_log_features(2,depth)
122
263
3145
4308
56214
Honest framing: the free-Lie dimension still grows like dn/n — exponential in depth, same complexity class as the tensor coordinates, just divided down. The real payoff isn't asymptotic; it's that these coordinates are non-redundant. A downstream model fed raw tensor coordinates has to implicitly learn the shuffle constraints from data; a model fed log-signature coordinates never sees the redundancy in the first place.

4.2 Primitive elements and Friedrichs' criterion

§1.6 and §1.7 are two views of one fact. Extend vv1+1v from V to all of T(V) as an algebra homomorphism (i.e. Δ(uw)=Δ(u)Δ(w) under concatenation), and (T(V),,Δ) is a graded bialgebra. Ree's theorem: a series X is grouplike for this Δ (i.e. Δ(X)=XX) exactly when its coefficients obey the shuffle relations — so §1.7's numerical check was a check of grouplike-ness, stated in coordinates rather than in Δ.

Friedrichs' criterion is the companion fact for the logarithm: if S is grouplike then logS is primitiveΔ(logS)=logS1+1logS — and the primitive elements of this bialgebra are exactly Lie(V), the free Lie algebra generated by V under the commutator [a,b]=abba. This is the ring-theory payoff promised in CLAUDE.md: grouplike-ness (a statement about the tensor algebra as an associative ring) and Lie-ness (a statement about the same underlying space as a Lie algebra under the commutator) are dual sides of one theorem, not two separate facts bolted together.

Turned into coordinates, primitivity says the exact opposite of §1.7's grouplike identity: instead of a shuffle sum equalling a product, it vanishes. For nonempty words u,v, log-signature coefficients satisfy wuvXw=0. On the §1.1 path's log-signature (computed below in §4.3):

identityvalue
2·X112+X121 (shuffle of "1" and "12")2.1×10⁻¹⁴
X212+2·X122 (shuffle of "2" and "12")5.3×10⁻¹⁵
✓ verified numerically — zero to floating-point noise

This is exactly the identity test_log_signature_is_primitive_under_shuffle checks with hypothesis in §4.6, over random paths and random word pairs rather than one fixed example.

4.3 Computing the log in the truncated tensor algebra

Write S=1+x (x has no level-0 part — it's the levels 1..depth already computed in §3.3). Because x is nilpotent at any truncation (its k-th power has no nonzero terms below level k), the ordinary power series for the logarithm is not an approximation here — it's exact once truncated to depth:

logS = xx22 +x33

_backend.py's _log_signature_full_numpy computes exactly this, reusing the same level-list / Kronecker-product machinery as §3.3's signature reference (refactored into a shared _tensor_multiply_levels helper — concatenation-as-ring-product, the same operation whether it's building the exponential of an increment or multiplying two partial log-series terms):

def _tensor_multiply_levels(a, b, dim, depth):
    out = []
    for level in range(depth + 1):
        acc = np.zeros(dim**level)
        for left in range(level + 1):
            acc = acc + np.kron(a[left], b[level - left])
        out.append(acc)
    return out

def _log_signature_full_numpy(path, depth):
    levels = _signature_levels_numpy(path, depth)
    x = [np.zeros(1)] + [levels[l].copy() for l in range(1, depth + 1)]
    log_levels = [np.zeros(dim**l) for l in range(depth + 1)]
    term, sign = x, 1.0
    for k in range(1, depth + 1):
        for level in range(depth + 1):
            log_levels[level] += sign * term[level] / k
        if k < depth:
            term = _tensor_multiply_levels(term, x, dim, depth)
        sign = -sign
    return np.concatenate(log_levels[1:])

On the §1.1 path, level 1 is unchanged from the raw signature (the linear term of log(1+x) is just x itself — same net displacement as §1.3), and level 2 is now exactly antisymmetric, not merely determined-but-present the way §1.4 found for the raw signature:

coordinate 1coordinate 2
log-sig level 16.8492572.373225
raw S¹ (from §1.3, for comparison)6.8492572.373225
log level 2col 1col 2
row 1≈0 (3.6×10⁻¹⁵)−7.171706
row 27.171706≈0
✓ verified numerically — diagonal zero to floating-point noise

4.4 The Hall basis and RoughPy's to_logsignature

_log_signature_full_numpy is honest but wasteful: it hands back the full n_features(dim, depth)-sized redundant representation, not the n_log_features(dim, depth) free coordinates from §4.1. Getting the minimal representation means picking an actual basis of Lie(V). The standard choice is a Hall basis built from Lyndon words (Chen–Fox–Lyndon theorem): a word is Lyndon if it is strictly smaller, lexicographically, than every one of its own rotations; each Lyndon word factors uniquely into two shorter Lyndon words (its standard factorisation), and bracketing that factorisation recursively gives one basis element per Lyndon word — a genuine basis of Lie(V), of exactly the dimension §4.1 predicts.

RoughPy computes this rather than us having to: ctx.to_logsignature(sig) is documented as "equivalent to tensor_to_lie(signature.log())" — RoughPy's own words for precisely the log-then-project-to-Lie pipeline just built by hand in §4.3. For the §1.1 path at depth 3, width 2 (5 = n_log_features(2, 3) coordinates, matching §4.1's table):

basis bracketvalue
[1] (= word "1")6.849257
[2] (= word "2")2.373225
[1,2]−7.171706
[1,[1,2]]−0.843217
[2,[1,2]]−5.183520
✓ verified numerically — matches the §4.3 tensor-log coordinates via the projection in §4.5

The bracket [1,2]'s value, −7.171706, is exactly half of §1.4's raw signed-area combination S12S21=14.3434: the Lévy area from §1.4 is the level-2 log-signature, up to that factor of 2 from how the bracket [1,2]=e1e2e2e1 is normalised against the raw tensor coordinates.

4.5 A third RoughPy bug: broken basis indexing

Both silent, same family as §2.3's.

Bug 3 · LieBasis.key_to_index and Lie.__getitem__(key) both wrong

The obvious way to line a Lie element's coefficients up with lie_basis.index_to_key(i)'s canonical order is to index by key directly. Both routes give wrong answers with no error:

basis.key_to_index(basis.index_to_key(i))  # always returns 5, out of range, for every i
logsig[basis.index_to_key(i)]              # returns repeated, wrong values for i ≥ 2
by_label = {str(item.key()): item.value().to_float() for item in logsig}
[by_label.get(str(basis.index_to_key(i)), 0.0) for i in range(basis.dimension)]

Iterating the Lie object and matching on the string form of each key -- str(item.key()) against str(basis.index_to_key(i)) -- was the only approach found that reliably lines values up with the canonical order. Worth flagging because it's easy to be misled by the correct-looking output: logsig[basis.index_to_key(2)] prints as 6.84926, a plausible-looking number (it's actually the level-1 coefficient for word "1"), not an obvious garbage value.

Caught by test_log_signature_matches_bracket_expansion_of_full_tensor_log: expanding the brackets [1,2], [1,[1,2]], [2,[1,2]] by hand as tensor words (e.g. [1,[1,2]]=word(1,1,2)2·word(1,2,1)+word(2,1,1)) and cross-checking against §4.3's independent numpy tensor log turned up index-5-out-of-bounds crashes and silently-wrong values before the string-label workaround was in place — the same pattern as §2.3: treat the algebra as a test, not an assumption, and bugs like this surface immediately instead of contaminating a benchmark three chapters later.

4.6 The correctness oracle: primitivity as a shuffle-vanishing test

_log_signature_full_numpy isn't a second backend — it returns full redundant tensor coordinates, not the Hall-basis ones, so it's never compared elementwise against log_signature(). Its job is to make the primitivity identity from §4.2 executable as a property test, the log-signature analogue of §1.7's shuffle test for the plain signature:

@given(
    path=arrays(dtype=float, shape=(6, 2), elements=st.floats(-5, 5, allow_nan=False)),
    u=st.tuples(st.integers(1, 2)),
    v=st.tuples(st.integers(1, 2), st.integers(1, 2)),
)
def test_log_signature_is_primitive_under_shuffle(path, u, v):
    log_full = dict(zip(_words(2, 3), _log_signature_full_numpy(path, depth=3)))
    total = sum(log_full[w] for w in _shuffles(u, v))
    assert total == pytest.approx(0.0, abs=1e-6)

This is the first hypothesis-based property test (listed as still open since §2.4): rather than one fixed path, it generates 50 random 6-point paths and random word pairs and checks the shuffle-vanishing identity holds on all of them. It's a meaningfully different check from §4.5's bracket-expansion test — that one catches indexing bugs in the RoughPy extraction; this one catches errors in the log-series arithmetic itself (a wrong sign or a missing /k would break primitivity, but wouldn't necessarily break the single fixed-path bracket-expansion numbers, which is why both exist).

4.7 Wiring it into the transformer, and where it stands

SignatureTransformer gained an output parameter, mirroring §2.2's existing signature() wiring rather than adding a new code path:

compute = log_signature if self.output == "log_signature" else signature
...
out[t] = compute(path, self.depth, backend=self.backend)

get_feature_names_out() returns labels like logsig_[1,[1,2]] instead of sig_112. The causal-alignment guarantee from §2.2 is unchanged and covered by its own test (test_log_signature_causal_alignment) rather than assumed to carry over.

Scope decision, stated honestly: output="log_signature" only works with backend="roughpy" for now. A general-dimension, general-depth numpy Lyndon-basis reduction (rather than just the depth-3, width-2 bracket expansion hand-derived in §4.5) and log-signature support for the iisignature backend were both real gaps as of this chapter — same honesty standard as §1.9's backend spike and §2.4's status table. (The iisignature log-signature backend arrives in Part II, where the benchmark needs it; the general numpy Lyndon reduction was never needed, because the numpy path stayed a test oracle.) Factorial rescale is also not implemented for log-signature output (it would need to be indexed by bracket depth rather than word length) and raises a clear ValueError rather than silently doing the wrong thing.
status
tests34 passed (up from 18 in Chapter 3), including the first hypothesis property test
log-signature backendroughpy only; matches the independent numpy oracle at every checked coordinate
transformeroutput="signature" (default) or "log_signature", same causal guarantee either way
still open at this pointgeneral Lyndon-basis reduction in numpy, iisignature log-signature support, rescale for log output, CI, the streaming engine
Chapter 5

Closing Out v0.1

5.1 The last oracle: invariance under time reparametrization

The v0.1 plan named five correctness oracles: Chen's identity (§1.6, §3.3), the shuffle identity (§1.7), free-Lie-algebra membership (§4.6's primitivity test), backend agreement (§3.4), and invariance under time reparametrization — the one still open at the end of Chapter 4. It says: resampling the same trajectory more densely must not change its signature, because the signature is meant to characterize a path geometrically, not the clock used to record it.

For a piecewise-linear path this is exact, not approximate, and the reason is algebraic rather than numerical. Splitting a straight segment into two collinear sub-segments a and b (both scalar multiples of the same direction vector) means every tensor power of a and b commutes — ab=ba whenever a and b point the same way — so the truncated exponentials multiply the way ordinary scalar exponentials do:

exp(a)·exp(b)=exp(a+b)

the same identity §1.6/§3.3 use for concatenating unrelated increments, specialized to the case where the increments happen to be parallel. test_signature_invariant_under_time_reparametrization (tests/test_backend.py) turns this into a hypothesis property test: it generates random 5-point paths and random sets of extra points inserted along existing segments, and asserts the resampled path's signature matches the original's to 1e-8 — exact equality up to floating-point roundoff, not a loose tolerance standing in for an approximation.

All five oracles are now implemented. That closes the open-items line carried forward since §2.4.

5.2 CI, and staying off PyPI on purpose

Every identity in this document was, until now, something proven by running pytest locally and trusting the last run stayed valid. .github/workflows/ci.yml removes that trust requirement: every push to main and every pull request installs rollsig with its test and iisignature extras and runs the full suite on Python 3.11 and 3.12. If a future change breaks Chen's identity or causal alignment, it shows up on the PR, not three chapters later.

Scope decision, stated honestly: the package-skeleton plan also called for an early PyPI publish. That is deliberately not done, here or since — claiming a name in a global namespace is a public, not-easily-reversed action, and this package is installed from the repository instead. CI, the license, and the skeleton are in place, which is what "pip-installable" needs to mean here.

Also decided here: no separate docs/math.md. The plan asked for a short expository note, but this document already is that note, written chapter by chapter as the code was built rather than distilled after the fact — a second file would only duplicate it out of sync.

5.3 Where v0.1 actually stands

status
tests35 passed, enforced in CI on Python 3.11 and 3.12
correctness oraclesall five planned for v0.1: Chen's identity, shuffle identity, primitivity/free Lie algebra, time-reparametrization invariance, backend agreement
transformersignature or log_signature output, causal by construction and by test, with basepoint/time-augmentation/lead-lag/rescale preprocessing
math write-upthis document; no separate docs/math.md
still open at the end of Part Igeneral Lyndon-basis reduction in numpy, iisignature log-signature support (Part II builds it), rescale for log output, the streaming engine (Part III builds it)

v0.2 is next: the Optiver realized-volatility benchmark against reproduced baselines. Unlike this chapter's closed algebraic identities, that one ends in a number that could go either way — the honesty constraint applies there directly.