tpattern 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tpattern/methods.py ADDED
@@ -0,0 +1,132 @@
1
+ """
2
+ Machine-generated Methods statement — the settings *and their semantics*.
3
+ =========================================================================
4
+
5
+ The single most transparent thing an open implementation can do is state, in full,
6
+ not only *which* settings were used but *what each one does*. Reporting a value
7
+ ("frequent-event exclusion = 1.50") does not make a result reproducible if the
8
+ value's behaviour is hidden — two tools can read the same number and act
9
+ differently (see the note's Section 3.1). So `methods_text()` emits a complete,
10
+ paste-ready Methods paragraph in which every parameter is accompanied by its
11
+ operational definition, generated from the actual `Config` that was run.
12
+
13
+ from tpattern import methods_text
14
+ print(methods_text(config, observations=obs, calibration=result))
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ VERSION = "0.1.0"
20
+ CONCEPT_DOI = "10.5281/zenodo.21397543"
21
+
22
+
23
+ def methods_text(config, *, observations=None, calibration=None,
24
+ cite: bool = True) -> str:
25
+ """Return a Methods-section description of a tpattern analysis.
26
+
27
+ Every setting is stated with its operational definition, so the parameter
28
+ *semantics* travel with the analysis, not merely their values. Pass the
29
+ `observations` to include the sample size and total observation time, and a
30
+ `CalibrationResult` to describe the surrogate null and error control.
31
+ """
32
+ s = []
33
+
34
+ # --- provenance ---
35
+ if cite:
36
+ s.append(
37
+ f"T-pattern detection was performed with tpattern (v{VERSION}; "
38
+ f"Callaway, 2026; https://doi.org/{CONCEPT_DOI}), an open-source "
39
+ f"implementation of Magnusson's (2000) T-pattern algorithm.")
40
+ else:
41
+ s.append("T-pattern detection was performed with tpattern, an open-source "
42
+ "implementation of Magnusson's (2000) T-pattern algorithm.")
43
+
44
+ # --- data ---
45
+ if observations is not None:
46
+ n_obs = len(observations)
47
+ n_ev = sum(len(o.events) for o in observations)
48
+ T = sum(max(o.end - o.start, 1) for o in observations)
49
+ s.append(f"The sample comprised {n_obs} observations containing {n_ev} "
50
+ f"event occurrences over {T} time units of total observation window.")
51
+
52
+ # --- core detection ---
53
+ s.append(
54
+ f"For each ordered pair of event types (A, B), the critical interval was the "
55
+ f"largest time window in which B followed A more often than expected under the "
56
+ f"NX/T baseline, where the baseline is the probability of at least one B in a "
57
+ f"window of that width given B's overall rate; significance was the upper-tail "
58
+ f"binomial probability of the observed number of A-to-B matches, thresholded at "
59
+ f"α = {config.alpha}. Occurrences were matched one to one by greedy interval "
60
+ f"scheduling, and each event occurrence could fill at most one role within a "
61
+ f"pattern (distinct-token rule). Patterns were built bottom-up into a binary "
62
+ f"hierarchy and were required to recur at least {config.min_occurrence} times.")
63
+
64
+ # --- frequent-event exclusion (the setting whose semantics matter most) ---
65
+ if getattr(config, "exclude_events", None) is not None:
66
+ ex = config.exclude_events
67
+ if ex:
68
+ s.append(f"The following event types were excluded from pattern "
69
+ f"construction (retained for baseline counts only): "
70
+ f"{', '.join(ex)}.")
71
+ else:
72
+ s.append("No event types were excluded from pattern construction.")
73
+ elif getattr(config, "freq_exclude", None) is not None:
74
+ s.append(
75
+ f"Event types whose mean number of occurrences per observation exceeded "
76
+ f"{config.freq_exclude} were excluded from building patterns (the "
77
+ f"frequent-event exclusion); they were retained in the univariate baseline "
78
+ f"counts. Note that this threshold is defined on mean occurrences per "
79
+ f"observation.")
80
+ else:
81
+ s.append("No frequent-event exclusion was applied; all event types were "
82
+ "eligible to build patterns.")
83
+
84
+ # --- concurrency handling ---
85
+ if getattr(config, "min_lag", 0) and config.min_lag >= 1:
86
+ s.append(
87
+ f"A genuine temporal gap of at least {config.min_lag} time unit(s) was "
88
+ f"required between two linked events (min_lag = {config.min_lag}); events "
89
+ f"sharing a timestamp were therefore treated as co-occurrence and excluded "
90
+ f"from directional pattern detection.")
91
+ else:
92
+ s.append(
93
+ "Events sharing a timestamp were permitted to form directional links "
94
+ "(min_lag = 0); where timestamps are coarsely resolved this treats "
95
+ "co-timed events as ordered, and such links should be read as "
96
+ "co-occurrence rather than sequence.")
97
+
98
+ # --- reduction / pruning options ---
99
+ if getattr(config, "completeness", True):
100
+ s.append("Patterns whose every occurrence was contained within a longer "
101
+ "detected pattern were removed (completeness competition).")
102
+ if getattr(config, "lumping_factor", None) is not None:
103
+ s.append(f"A lumping factor of {config.lumping_factor} was applied: when a "
104
+ f"pair (A, B) formed with a forward conditional probability above this "
105
+ f"value, the dependent terminal was removed from the remaining search.")
106
+ if getattr(config, "min_samples_frac", None) is not None:
107
+ s.append(f"Patterns were additionally required to occur in at least "
108
+ f"{config.min_samples_frac:.0%} of observations.")
109
+
110
+ # --- calibration / significance ---
111
+ if calibration is not None:
112
+ null_name = {"profile": "profile-preserving", "rotation": "rotation",
113
+ "shuffle": "shuffling"}.get(calibration.null, calibration.null)
114
+ s.append(
115
+ f"Every detected pattern was then tested against a {null_name} surrogate "
116
+ f"null: {calibration.B} surrogate datasets were generated and run through "
117
+ f"the identical detection pipeline, giving a Monte-Carlo empirical p-value "
118
+ f"per pattern (so the test is automatically corrected for the selection "
119
+ f"over candidate intervals). Because the empirical p-value cannot fall "
120
+ f"below 1/({calibration.B}+1), family-wise claims require the surrogate "
121
+ f"count to be raised accordingly. Multiplicity was controlled by the "
122
+ f"Benjamini-Hochberg false-discovery rate (q ≤ {calibration.q_target}) "
123
+ f"and, for confirmatory claims, the Holm family-wise error rate "
124
+ f"(α = {calibration.alpha}), each stratified by pattern level.")
125
+
126
+ if cite:
127
+ s.append(
128
+ f"The analysis is fully reproducible from the archived software "
129
+ f"(https://doi.org/{CONCEPT_DOI}); the settings above, including each "
130
+ f"parameter's operational definition, are emitted by the software itself.")
131
+
132
+ return " ".join(s)
tpattern/pattern.py ADDED
@@ -0,0 +1,129 @@
1
+ """
2
+ The T-pattern data structure.
3
+ =============================
4
+
5
+ A T-pattern is a **binary tree**. This mirrors how Magnusson (2000) builds
6
+ patterns: never more than two things are joined at once. Bigger patterns are
7
+ made by joining a pattern to another pattern (or single event), so the tree
8
+ grows one critical-interval relationship at a time.
9
+
10
+ Level 0 (a "terminal"): a single event type, e.g. Challenge_Pressure
11
+ Level 1: (A B) two events linked by a critical interval
12
+ Level 2: (A (B C)) an event linked to a level-1 pattern
13
+ ...
14
+
15
+ Reading order is always left-to-right in time: in `(A B)`, every occurrence has
16
+ an A that is *followed by* a B within the critical interval discovered for that
17
+ link.
18
+
19
+ Occurrences
20
+ -----------
21
+ Each pattern carries a concrete list of the times it actually happened. One
22
+ occurrence is an :class:`Instance`: which observation it is in, and the
23
+ [t_start, t_end] span it covers (t_start = time of the first/left-most event,
24
+ t_end = time of the last/right-most event). The *number of occurrences* N is
25
+ simply ``len(pattern.instances)`` — this is the N reported in the paper.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from dataclasses import dataclass, field
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Instance:
35
+ """One realised occurrence of a pattern within one observation.
36
+
37
+ `tokens` holds the identities of the *distinct raw event occurrences* that
38
+ make up this instance. THEME requires every pattern occurrence to be built
39
+ from distinct events — one event may not fill two roles — so when two
40
+ sub-patterns are joined their token sets must be disjoint. Terminals carry a
41
+ single token (the event's unique id).
42
+ """
43
+
44
+ obs: int # index of the observation in the sample
45
+ start: int # time (ms) of the left-most event
46
+ end: int # time (ms) of the right-most event
47
+ tokens: frozenset = frozenset()
48
+
49
+
50
+ @dataclass
51
+ class Pattern:
52
+ """A node in the T-pattern tree.
53
+
54
+ A *terminal* has ``left is None and right is None`` and an ``event`` label.
55
+ A *composite* has ``left`` and ``right`` sub-patterns joined by the critical
56
+ interval ``ci = (d1, d2)`` (only meaningful for composites).
57
+ """
58
+
59
+ event: str | None = None # set only for terminals
60
+ left: "Pattern | None" = None
61
+ right: "Pattern | None" = None
62
+ ci: tuple[int, int] | None = None # (d1, d2) critical interval, ms
63
+ p_value: float | None = None # significance of the link
64
+ instances: list[Instance] = field(default_factory=list)
65
+
66
+ # ------------------------------------------------------------------ counts
67
+ @property
68
+ def N(self) -> int:
69
+ """Number of occurrences across the whole sample."""
70
+ return len(self.instances)
71
+
72
+ @property
73
+ def is_terminal(self) -> bool:
74
+ return self.left is None and self.right is None
75
+
76
+ # ----------------------------------------------------------- tree geometry
77
+ def leaves(self) -> list[str]:
78
+ """Event types in left-to-right (temporal) order, with repeats kept."""
79
+ if self.is_terminal:
80
+ return [self.event] # type: ignore[list-item]
81
+ return self.left.leaves() + self.right.leaves()
82
+
83
+ @property
84
+ def length(self) -> int:
85
+ """Pattern length = number of terminal events (THEME 'length')."""
86
+ return len(self.leaves())
87
+
88
+ @property
89
+ def level(self) -> int:
90
+ """Hierarchical level = tree depth. Terminals are level 0."""
91
+ if self.is_terminal:
92
+ return 0
93
+ return 1 + max(self.left.level, self.right.level)
94
+
95
+ @property
96
+ def has_loop(self) -> bool:
97
+ """A 'loop' is a recurrence: the same event type appears twice or more.
98
+
99
+ THEME flags these (hasloop = 1) as recycling structures — e.g. the
100
+ interception-mediated loops that characterise saved-shot sequences.
101
+ """
102
+ seen = self.leaves()
103
+ return len(set(seen)) < len(seen)
104
+
105
+ # ------------------------------------------------------------- identity/str
106
+ def signature(self) -> str:
107
+ """A canonical string that uniquely identifies the tree's *shape*.
108
+
109
+ Two patterns with the same signature are the same pattern (same events,
110
+ same bracketing). Used to deduplicate during detection. Critical-interval
111
+ widths are deliberately excluded so that the same structure discovered
112
+ via slightly different intervals is treated as one pattern.
113
+ """
114
+ if self.is_terminal:
115
+ return self.event # type: ignore[return-value]
116
+ return f"({self.left.signature()} {self.right.signature()})"
117
+
118
+ def __str__(self) -> str:
119
+ """Human-readable rendering, e.g. cross → (challenge → shot_goal)."""
120
+ if self.is_terminal:
121
+ return self.event # type: ignore[return-value]
122
+ l, r = str(self.left), str(self.right)
123
+ if not self.left.is_terminal:
124
+ l = f"({l})"
125
+ if not self.right.is_terminal:
126
+ r = f"({r})"
127
+ return f"{l} → {r}"
128
+
129
+ __repr__ = __str__
tpattern/randomise.py ADDED
@@ -0,0 +1,206 @@
1
+ """
2
+ Randomisation null — the Type-I-error control THEME can't easily give you.
3
+ ==========================================================================
4
+
5
+ The analytic critical-interval test assumes that, under the null, the second
6
+ event is scattered independently and uniformly. Real event streams violate that
7
+ (events cluster, densities vary within a possession). So a pattern can clear the
8
+ analytic 0.005 bar and still be an artefact of that clustering rather than a
9
+ genuine temporal relationship.
10
+
11
+ The fix is an *empirical* null: destroy the real cross-event timing while keeping
12
+ each event type's own frequency and internal spacing, re-run the entire
13
+ detection, and see which patterns still show up. A pattern that keeps appearing
14
+ in randomised data — where by construction there is no genuine structure — is not
15
+ trustworthy. This is exactly what makes the result defensible to a sceptical
16
+ reviewer, and it is the honest way to decide which borderline patterns to keep.
17
+
18
+ Two surrogate schemes (THEME's "Shuffling and Rotation"), 10 runs each:
19
+
20
+ * Rotation — within each observation, cyclically shift every event type's
21
+ time-line by its own random offset (mod window length). This keeps
22
+ each type's count and internal inter-event spacing intact but
23
+ scrambles the *relative* timing between types. It is the stricter,
24
+ more structure-preserving null.
25
+ * Shuffling — within each observation, keep the set of event *times* but randomly
26
+ permute which event *type* sits at each time. This preserves the
27
+ overall event density and timing but destroys type-specific
28
+ temporal relationships.
29
+
30
+ For each real pattern we report an empirical p-value: the fraction of surrogate
31
+ runs in which the *same* pattern (identical signature) recurs with an occurrence
32
+ count at least as high as observed. Empirical p = 0 means the pattern never
33
+ arose by chance in any surrogate.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import random
39
+ from collections import defaultdict
40
+ from dataclasses import dataclass
41
+
42
+ from .detect import Config, Engine
43
+ from .io import Observation
44
+ from .pattern import Pattern
45
+
46
+
47
+ def rotate(obs: Observation, rng: random.Random) -> Observation:
48
+ """Rotation surrogate for one observation (per-type cyclic time shift)."""
49
+ T = max(obs.T, 1)
50
+ by_type: dict[str, list[int]] = defaultdict(list)
51
+ for t, ev in obs.events:
52
+ by_type[ev].append(t)
53
+ new_events: list[tuple[int, str]] = []
54
+ for ev, times in by_type.items():
55
+ offset = rng.randrange(T)
56
+ for t in times:
57
+ nt = obs.start + ((t - obs.start + offset) % T)
58
+ new_events.append((nt, ev))
59
+ new_events.sort()
60
+ return Observation(name=obs.name, start=obs.start, end=obs.end, events=new_events)
61
+
62
+
63
+ def shuffle(obs: Observation, rng: random.Random) -> Observation:
64
+ """Shuffling surrogate for one observation (permute type labels over times)."""
65
+ times = [t for t, _ in obs.events]
66
+ types = [ev for _, ev in obs.events]
67
+ rng.shuffle(types)
68
+ new_events = sorted(zip(times, types))
69
+ return Observation(name=obs.name, start=obs.start, end=obs.end, events=new_events)
70
+
71
+
72
+ def build_profiles(observations) -> dict:
73
+ """Pooled within-sequence position (u = (t-start)/T) for each event type.
74
+
75
+ This is each type's empirical *marginal temporal intensity* — where in a
76
+ sequence it tends to occur (shots late ~0.9, passes early ~0.3). It is the
77
+ structure the profile-preserving null keeps.
78
+ """
79
+ import numpy as np
80
+ pos: dict[str, list[float]] = defaultdict(list)
81
+ for o in observations:
82
+ span = max(o.end - o.start, 1)
83
+ for t, ev in o.events:
84
+ # clamp to [0, 1] so a surrogate can never be placed outside the
85
+ # observation window even if an event's time falls on/just past a border.
86
+ pos[ev].append(min(1.0, max(0.0, (t - o.start) / span)))
87
+ return {ev: np.asarray(v) for ev, v in pos.items()}
88
+
89
+
90
+ def profile_surrogate(observations, profiles, rng) -> list:
91
+ """Profile-preserving surrogate (null N2).
92
+
93
+ For each observation and each event type, redraw that type's event times from
94
+ its *own* pooled marginal position profile (with replacement), keeping the
95
+ per-observation count. This preserves (i) each type's count per sequence and
96
+ (ii) each type's marginal temporal profile, while destroying any *cross-type*
97
+ temporal coupling. Significance against this null therefore isolates genuine
98
+ coordination between event types — beyond the fact that each type has its own
99
+ characteristic timing.
100
+
101
+ `rng` is a numpy Generator (fast vectorised sampling).
102
+ """
103
+ out = []
104
+ for o in observations:
105
+ span = max(o.end - o.start, 1)
106
+ counts: dict[str, int] = defaultdict(int)
107
+ for _, ev in o.events:
108
+ counts[ev] += 1
109
+ events: list[tuple[int, str]] = []
110
+ for ev, c in counts.items():
111
+ us = rng.choice(profiles[ev], size=c, replace=True)
112
+ for u in us:
113
+ events.append((int(round(o.start + float(u) * span)), ev))
114
+ events.sort()
115
+ out.append(Observation(name=o.name, start=o.start, end=o.end, events=events))
116
+ return out
117
+
118
+
119
+ def randomise_sample(observations, method: str, rng: random.Random):
120
+ fn = rotate if method == "rotation" else shuffle
121
+ return [fn(o, rng) for o in observations]
122
+
123
+
124
+ @dataclass
125
+ class NullResult:
126
+ """Summary of the randomisation null for one detection."""
127
+
128
+ real: list[Pattern] # patterns detected in real data
129
+ runs: dict[str, list[list[Pattern]]] # method -> list of per-run pattern lists
130
+ empirical_p: dict[str, float] # real-pattern signature -> empirical p
131
+ n_runs_total: int
132
+
133
+ def level_counts(self):
134
+ """Mean number of composite patterns per level: real vs surrogate."""
135
+ def by_level(pats):
136
+ c = defaultdict(int)
137
+ for p in pats:
138
+ if p.level >= 1:
139
+ c[p.level] += 1
140
+ return c
141
+
142
+ real_c = by_level(self.real)
143
+ rand_lists = [p for runs in self.runs.values() for p in runs]
144
+ rand_c = defaultdict(list)
145
+ for pats in rand_lists:
146
+ c = by_level(pats)
147
+ lv = set(c) | set(real_c)
148
+ for k in lv:
149
+ rand_c[k].append(c.get(k, 0))
150
+ levels = sorted(set(real_c) | set(rand_c))
151
+ return [
152
+ (lv, real_c.get(lv, 0),
153
+ sum(rand_c.get(lv, [])) / max(len(rand_c.get(lv, [])), 1))
154
+ for lv in levels
155
+ ]
156
+
157
+ def survivors(self, threshold: float = 0.0) -> list[Pattern]:
158
+ """Real composite patterns with empirical p <= threshold.
159
+
160
+ threshold = 0.0 keeps only patterns that never recurred in any surrogate
161
+ at equal-or-greater strength — the strictest, cleanest filter given a
162
+ finite number of runs.
163
+ """
164
+ return [p for p in self.real
165
+ if p.level >= 1 and self.empirical_p.get(p.signature(), 1.0) <= threshold]
166
+
167
+
168
+ def run_null(observations, config: Config | None = None,
169
+ n_runs: int = 10, seed: int = 12345) -> NullResult:
170
+ """Detect on real data, then on 10 rotation + 10 shuffling surrogates."""
171
+ config = config or Config()
172
+
173
+ real = Engine(observations, config).detect()
174
+ real_index: dict[str, int] = {}
175
+ for p in real:
176
+ if p.level >= 1:
177
+ real_index[p.signature()] = p.N
178
+
179
+ runs: dict[str, list[list[Pattern]]] = {"rotation": [], "shuffling": []}
180
+ # count, per real signature, how many surrogate runs match-or-beat it
181
+ beat: dict[str, int] = defaultdict(int)
182
+
183
+ # fixed per-method offset — NOT hash(method), whose value is salted per Python
184
+ # process (PYTHONHASHSEED), which would make identical `seed` arguments produce
185
+ # different surrogates across runs despite the reproducibility the signature promises.
186
+ method_offset = {"rotation": 0, "shuffling": 1}
187
+ for method in ("rotation", "shuffling"):
188
+ for r in range(n_runs):
189
+ rng = random.Random((method_offset[method] * 2246822519
190
+ ^ (r * 2654435761) ^ seed) & 0xFFFFFFFF)
191
+ surrogate = randomise_sample(observations, method, rng)
192
+ pats = Engine(surrogate, config).detect()
193
+ runs[method].append(pats)
194
+ rand_best: dict[str, int] = {}
195
+ for p in pats:
196
+ if p.level >= 1:
197
+ sig = p.signature()
198
+ if p.N > rand_best.get(sig, 0):
199
+ rand_best[sig] = p.N
200
+ for sig, n_real in real_index.items():
201
+ if rand_best.get(sig, 0) >= n_real:
202
+ beat[sig] += 1
203
+
204
+ total = n_runs * 2
205
+ empirical_p = {sig: beat.get(sig, 0) / total for sig in real_index}
206
+ return NullResult(real=real, runs=runs, empirical_p=empirical_p, n_runs_total=total)
tpattern/report.py ADDED
@@ -0,0 +1,201 @@
1
+ """
2
+ Reporting — the output layer THEME does poorly.
3
+
4
+ Three things:
5
+
6
+ 1. `patterns_table` — a tidy results table for detected patterns: the pattern
7
+ string, N, length, level, loop flag, critical interval, and (when a
8
+ calibration is supplied) the surrogate empirical p with **repeated-testing
9
+ correction** (Benjamini–Hochberg q for screening, Holm/FWER keep for
10
+ confirmation). Writes CSV and returns the rows.
11
+
12
+ 2. `forest_plot` — effect sizes with confidence intervals (odds ratios from the
13
+ group/outcome contrasts) as a forest plot, the standard way to show
14
+ which contrasts matter and how uncertain they are.
15
+
16
+ 3. `report` — one call that writes the table, the top-pattern dendrograms and (if
17
+ given) the forest plot into an output folder, so a detection run yields a
18
+ ready-to-read report.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import csv
24
+ import math
25
+ import os
26
+
27
+ import matplotlib
28
+ matplotlib.use("Agg")
29
+ import matplotlib.pyplot as plt
30
+ from matplotlib.transforms import blended_transform_factory
31
+
32
+ from .pattern import Pattern
33
+ from .significance import CalibrationResult, Calibrated
34
+ from .viz import patterns_overview
35
+
36
+
37
+ # --------------------------------------------------------------------- table
38
+ def _fmt_ci(ci, unit):
39
+ if not ci:
40
+ return ""
41
+ return f"[{ci[0]},{ci[1]}]{unit}"
42
+
43
+
44
+ def interpret(pattern, N, fdr_q, *, q_target=0.05, ci_unit=""):
45
+ """A plain-English reading of one calibrated pattern — what it is, whether it
46
+ is trustworthy, and its timing. Used as the results table's `interpretation`
47
+ column so a non-specialist can read the output directly."""
48
+ if fdr_q <= q_target:
49
+ verdict, meaning = (f"Robust (survives FDR, q={fdr_q:.3g})",
50
+ "a genuine recurring sequence")
51
+ elif fdr_q <= 2 * q_target:
52
+ verdict, meaning = (f"Borderline (q={fdr_q:.3g})",
53
+ "near the significance threshold — treat with caution and "
54
+ "re-check with more surrogates")
55
+ else:
56
+ verdict, meaning = (f"Not significant after correction (q={fdr_q:.3g})",
57
+ "consistent with chance")
58
+ timing = ""
59
+ if pattern.ci and not pattern.is_terminal:
60
+ d1, d2 = pattern.ci
61
+ timing = (f"; its components are linked within {d1}–{d2}{ci_unit}"
62
+ if d1 != d2 else f"; components co-occur (0{ci_unit} apart)")
63
+ loop = " (a recycling loop: a repeated event type)" if pattern.has_loop else ""
64
+ plural = "occurrence" if N == 1 else "occurrences"
65
+ return f"{verdict}: {meaning}, {N} {plural}{timing}{loop}."
66
+
67
+
68
+ def patterns_table(source, outfile: str | None = None, ci_unit: str = "",
69
+ sort: str = "auto"):
70
+ """Build a results table from a list of `Pattern` or a `CalibrationResult`.
71
+
72
+ Returns a list of row dicts. If `outfile` is given, also writes CSV.
73
+ When given a CalibrationResult, includes p_emp, fdr_q, fwer_keep and sorts by
74
+ p_emp; otherwise sorts by N (descending).
75
+ """
76
+ calibrated = isinstance(source, CalibrationResult)
77
+ items = source.real if calibrated else [p for p in source if p.level >= 1]
78
+
79
+ rows = []
80
+ for it in items:
81
+ p = it.pattern if calibrated else it
82
+ row = {
83
+ "pattern": str(p),
84
+ "signature": p.signature(),
85
+ "N": p.N,
86
+ "length": p.length,
87
+ "level": p.level,
88
+ "loop": int(p.has_loop),
89
+ "critical_interval": _fmt_ci(p.ci, ci_unit),
90
+ }
91
+ if calibrated:
92
+ row["p_emp"] = round(it.p_emp, 4)
93
+ row["fdr_q"] = round(it.fdr_q, 4)
94
+ row["fwer_keep"] = int(it.fwer_keep)
95
+ row["analytic_strength"] = round(it.strength, 2) # -log10(analytic p); comparison only
96
+ row["interpretation"] = interpret(p, it.N, it.fdr_q, ci_unit=ci_unit)
97
+ rows.append(row)
98
+
99
+ key = sort
100
+ if sort == "auto":
101
+ key = "p_emp" if calibrated else "N"
102
+ if key == "N":
103
+ rows.sort(key=lambda r: (-r["N"], r["level"]))
104
+ elif key in ("p_emp", "fdr_q") and calibrated:
105
+ rows.sort(key=lambda r: (r[key], -r["N"]))
106
+
107
+ if outfile:
108
+ with open(outfile, "w", newline="") as fh:
109
+ w = csv.DictWriter(fh, fieldnames=list(rows[0].keys()) if rows else ["pattern"])
110
+ w.writeheader()
111
+ w.writerows(rows)
112
+ return rows
113
+
114
+
115
+ # --------------------------------------------------------------- forest plot
116
+ def forest_plot(items, outfile: str, title: str = "Effect sizes (odds ratios)",
117
+ xlabel: str = "odds ratio (log scale)"):
118
+ """Forest plot of odds ratios with 95% CIs.
119
+
120
+ `items` is a list of dicts, each: {label, or, lo, hi, p (optional), n (opt)}.
121
+ A reference line is drawn at OR = 1. Points whose CI excludes 1 are coloured.
122
+ """
123
+ items = list(items)
124
+ if not items:
125
+ return
126
+ n = len(items)
127
+ fig, ax = plt.subplots(figsize=(9, 0.6 * n + 1.4))
128
+ ys = list(range(n, 0, -1)) # top-to-bottom in given order
129
+
130
+ # fix x-range up front so label anchoring is stable on the log scale
131
+ los = [it["lo"] for it in items]; his = [it["hi"] for it in items]
132
+ ax.set_xscale("log")
133
+ ax.set_xlim(min(los) * 0.7, max(his) * 1.35)
134
+ ax.set_ylim(0.3, n + 0.9)
135
+ # label in axes-x (fixed left/right), data-y
136
+ tx = blended_transform_factory(ax.transAxes, ax.transData)
137
+
138
+ for y, it in zip(ys, items):
139
+ lo, hi, orr = it["lo"], it["hi"], it["or"]
140
+ sig = lo > 1 or hi < 1
141
+ colour = "#c0392b" if sig else "#7f8c8d"
142
+ ax.plot([lo, hi], [y, y], color=colour, lw=1.8, solid_capstyle="round")
143
+ ax.plot([orr], [y], "o", color=colour, ms=7)
144
+ lbl = it["label"]
145
+ if it.get("p") is not None:
146
+ lbl += f" (p={it['p']:.3g})"
147
+ ax.text(0.005, y + 0.18, lbl, transform=tx, ha="left", va="bottom", fontsize=8)
148
+ ax.text(0.995, y, f"{orr:.2f} [{lo:.2f}, {hi:.2f}]", transform=tx,
149
+ ha="right", va="center", fontsize=7.5, color="#555")
150
+
151
+ ax.axvline(1.0, color="#2c3e50", lw=1.0, ls="--")
152
+ ax.set_yticks([])
153
+ ax.set_xlabel(xlabel)
154
+ ax.set_title(title, fontsize=11)
155
+ for s in ("top", "right", "left"):
156
+ ax.spines[s].set_visible(False)
157
+ plt.tight_layout(); plt.savefig(outfile, dpi=150); plt.close()
158
+
159
+
160
+ # ------------------------------------------------------------------- report
161
+ def report(source, outdir: str, *, ci_unit: str = "", title: str = "T-pattern report",
162
+ effects=None, max_dendrograms: int = 8):
163
+ """Write a full report to `outdir`: table (CSV), top-pattern dendrograms and,
164
+ if `effects` given, a forest plot. Returns paths written."""
165
+ os.makedirs(outdir, exist_ok=True)
166
+ calibrated = isinstance(source, CalibrationResult)
167
+ patterns = [c.pattern for c in source.real] if calibrated else \
168
+ [p for p in source if p.level >= 1]
169
+
170
+ written = {}
171
+ written["table"] = os.path.join(outdir, "patterns_table.csv")
172
+ rows = patterns_table(source, written["table"], ci_unit=ci_unit)
173
+
174
+ if patterns:
175
+ written["dendrograms"] = os.path.join(outdir, "patterns_overview.png")
176
+ patterns_overview(patterns, written["dendrograms"],
177
+ max_rows=max_dendrograms, ci_unit=ci_unit)
178
+
179
+ if effects:
180
+ written["forest"] = os.path.join(outdir, "effect_sizes.png")
181
+ forest_plot(effects, written["forest"])
182
+
183
+ # short text summary
184
+ written["summary"] = os.path.join(outdir, "SUMMARY.txt")
185
+ with open(written["summary"], "w") as fh:
186
+ fh.write(f"{title}\n{'=' * len(title)}\n\n")
187
+ fh.write(f"patterns (level >= 1): {len(patterns)}\n")
188
+ if calibrated:
189
+ kept_fdr = len(source.kept('fdr'))
190
+ kept_fwer = len(source.kept('fwer'))
191
+ fh.write(f"null: {source.null} B={source.B} "
192
+ f"alpha={source.alpha} q_target={source.q_target}\n")
193
+ fh.write(f"kept (FDR q<={source.q_target}): {kept_fdr}\n")
194
+ fh.write(f"kept (FWER Holm): {kept_fwer}\n")
195
+ fh.write("\ntop patterns:\n")
196
+ for r in rows[:15]:
197
+ line = f" N={r['N']:>3} L{r['level']} {r['pattern']}"
198
+ if calibrated:
199
+ line += f" p_emp={r['p_emp']} q={r['fdr_q']}"
200
+ fh.write(line + "\n")
201
+ return written