From 8154a662f746bef04ef947318816940eb67e64ea Mon Sep 17 00:00:00 2001 From: zolizoli Date: Wed, 1 Jul 2026 00:25:49 +0200 Subject: [PATCH] Phase 4: recurrence dotplot + concordance grids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two grid presets on two different grids — self-similarity vs term dispersion. - layout/recurrence.py: recurrence_distances (token / char-n-gram Jaccard, dependency-free) and recurrence_matrix (threshold to a boolean dotplot). - presets/recurrence.py: recurrence_plot — the sentence x sentence grid via imshow (binary dotplot or distance heatmap). Accepts a precomputed distance matrix (e.g. embeddings from the [graph] extra). - layout/dispersion.py: term_offsets (per-term token offsets) and kwic/KWIC (keyword-in-context lines). - presets/concordance.py: concordance — a term x text lexical-dispersion plot, one tick per occurrence, one row per term. - Exports, examples (recurrence_demo, concordance_demo), docs, CHANGELOG. make ci green (148 tests, incl. Jaccard symmetry/bounds and offset/KWIC properties); mkdocs build --strict green; both grids render headlessly and were eyeballed (clean diagonal + symmetric echoes; per-term dispersion rows). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 ++ docs/api/concordance.md | 11 +++ docs/api/recurrence.md | 9 +++ docs/examples/concordance.md | 18 +++++ docs/examples/recurrence.md | 19 +++++ examples/concordance_demo.py | 24 ++++++ examples/recurrence_demo.py | 18 +++++ mkdocs.yml | 4 + src/lexograph/__init__.py | 13 ++- src/lexograph/layout/__init__.py | 7 ++ src/lexograph/layout/dispersion.py | 116 ++++++++++++++++++++++++++ src/lexograph/layout/recurrence.py | 117 +++++++++++++++++++++++++++ src/lexograph/presets/__init__.py | 10 ++- src/lexograph/presets/concordance.py | 94 +++++++++++++++++++++ src/lexograph/presets/recurrence.py | 104 ++++++++++++++++++++++++ tests/test_dispersion.py | 52 ++++++++++++ tests/test_presets.py | 62 +++++++++++++- tests/test_recurrence.py | 57 +++++++++++++ 18 files changed, 739 insertions(+), 3 deletions(-) create mode 100644 docs/api/concordance.md create mode 100644 docs/api/recurrence.md create mode 100644 docs/examples/concordance.md create mode 100644 docs/examples/recurrence.md create mode 100644 examples/concordance_demo.py create mode 100644 examples/recurrence_demo.py create mode 100644 src/lexograph/layout/dispersion.py create mode 100644 src/lexograph/layout/recurrence.py create mode 100644 src/lexograph/presets/concordance.py create mode 100644 src/lexograph/presets/recurrence.py create mode 100644 tests/test_dispersion.py create mode 100644 tests/test_recurrence.py diff --git a/CHANGELOG.md b/CHANGELOG.md index becd332..e4ed6fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ semantic versioning. ### Added +- Phase 4 grids: the two grid presets, each on a different grid. + - `recurrence_plot` — the sentence × sentence self-similarity dotplot (a text + against itself), via matplotlib `imshow`. Dependency-free token/character + Jaccard distance by default (`layout.recurrence_distances` / + `recurrence_matrix`), or a precomputed embedding distance matrix. + - `concordance` — a term × text lexical-dispersion plot, with + `layout.term_offsets` and keyword-in-context (`layout.kwic` / `KWIC`). - Phase 3 corkscrew: the 3-D walk. - `layout.walk3d_layout` — the 2-D walk lifted by a constant step in z. - `render.render_path_3d` — a matplotlib 3-D path renderer with per-segment diff --git a/docs/api/concordance.md b/docs/api/concordance.md new file mode 100644 index 0000000..6407207 --- /dev/null +++ b/docs/api/concordance.md @@ -0,0 +1,11 @@ +# Concordance + +A term's dispersion across the text, with keyword-in-context. + +::: lexograph.presets.concordance.concordance + +::: lexograph.layout.dispersion.term_offsets + +::: lexograph.layout.dispersion.kwic + +::: lexograph.layout.dispersion.KWIC diff --git a/docs/api/recurrence.md b/docs/api/recurrence.md new file mode 100644 index 0000000..89e22ab --- /dev/null +++ b/docs/api/recurrence.md @@ -0,0 +1,9 @@ +# Recurrence dotplot + +A text plotted against itself: the sentence × sentence self-similarity grid. + +::: lexograph.presets.recurrence.recurrence_plot + +::: lexograph.layout.recurrence.recurrence_distances + +::: lexograph.layout.recurrence.recurrence_matrix diff --git a/docs/examples/concordance.md b/docs/examples/concordance.md new file mode 100644 index 0000000..44fa165 --- /dev/null +++ b/docs/examples/concordance.md @@ -0,0 +1,18 @@ +# Concordance + +Where the main characters and themes fall across the chapter. Each term is a row; +each tick is one occurrence at its token offset. + +```python +--8<-- "examples/concordance_demo.py" +``` + +Run it with: + +```bash +uv run python examples/concordance_demo.py +``` + +It also prints keyword-in-context lines for one term via +`lexograph.layout.dispersion.kwic`. Pass `normalize=True` to scale the x-axis to +the fraction of the text rather than absolute token offset. diff --git a/docs/examples/recurrence.md b/docs/examples/recurrence.md new file mode 100644 index 0000000..77e7c63 --- /dev/null +++ b/docs/examples/recurrence.md @@ -0,0 +1,19 @@ +# Recurrence dotplot + +The bundled chapter plotted against itself. The strong main diagonal is each +sentence matching itself; the symmetric off-diagonal dots mark sentences that +echo one another — here, the repeated dialogue exchanges between the Bennets. + +```python +--8<-- "examples/recurrence_demo.py" +``` + +Run it with: + +```bash +uv run python examples/recurrence_demo.py +``` + +Pass `mode="distance"` for the full similarity heatmap, `shingle=k` for +character-`k`-gram similarity, or a precomputed `distances` matrix (e.g. an +embedding distance from the `[graph]` extra) to drive it from semantics. diff --git a/examples/concordance_demo.py b/examples/concordance_demo.py new file mode 100644 index 0000000..63236e7 --- /dev/null +++ b/examples/concordance_demo.py @@ -0,0 +1,24 @@ +"""Concordance demo: where the main characters fall across the chapter. + +Run with: uv run python examples/concordance_demo.py +Writes concordance.png to the current directory and prints KWIC for "Bingley". +""" + +from lexograph import concordance, load_demo_text +from lexograph.layout.dispersion import kwic + + +def main() -> None: + """Plot the dispersion of a few terms and print their keyword-in-context lines.""" + text = load_demo_text() + fig = concordance(text, ["Bennet", "Bingley", "wife", "daughters", "fortune"]) + fig.savefig("concordance.png", dpi=150) + print("Saved concordance.png") + + print('\nKWIC for "Bingley":') + for line in kwic(text, "Bingley", width=4): + print(f" {line.left:>30} [{line.keyword}] {line.right}") + + +if __name__ == "__main__": + main() diff --git a/examples/recurrence_demo.py b/examples/recurrence_demo.py new file mode 100644 index 0000000..3ff9703 --- /dev/null +++ b/examples/recurrence_demo.py @@ -0,0 +1,18 @@ +"""Recurrence-dotplot demo: a chapter plotted against itself. + +Run with: uv run python examples/recurrence_demo.py +Writes recurrence.png to the current directory. +""" + +from lexograph import load_demo_text, recurrence_plot + + +def main() -> None: + """Draw the sentence x sentence self-similarity grid of the bundled chapter.""" + fig = recurrence_plot(load_demo_text(), threshold=0.7) + fig.savefig("recurrence.png", dpi=150) + print("Saved recurrence.png") + + +if __name__ == "__main__": + main() diff --git a/mkdocs.yml b/mkdocs.yml index 87cb533..f961a0f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -52,12 +52,16 @@ nav: - examples/punctuation_spiral.md - examples/text_walk.md - examples/text_walk_3d.md + - examples/recurrence.md + - examples/concordance.md - examples/spine_demo.md - Troubleshooting: troubleshooting.md - API Reference: - Presets: - api/punctuation_spiral.md - api/text_walk.md + - api/recurrence.md + - api/concordance.md - Spine: - api/segment.md - api/layout.md diff --git a/src/lexograph/__init__.py b/src/lexograph/__init__.py index 7d57c28..ab87b0d 100644 --- a/src/lexograph/__init__.py +++ b/src/lexograph/__init__.py @@ -18,13 +18,20 @@ normalize_size, ) from lexograph.layout import ( + kwic, linear_layout, rendered_widths, spiral_layout, + term_offsets, walk3d_layout, walk_layout, ) -from lexograph.presets import punctuation_spiral, text_walk +from lexograph.presets import ( + concordance, + punctuation_spiral, + recurrence_plot, + text_walk, +) from lexograph.render import frame_axes, render_path, render_path_3d, render_points from lexograph.segment import segment @@ -35,6 +42,8 @@ "walk3d_layout", "spiral_layout", "rendered_widths", + "term_offsets", + "kwic", "normalize_size", "categorical_colors", "continuous_colors", @@ -45,6 +54,8 @@ "frame_axes", "punctuation_spiral", "text_walk", + "recurrence_plot", + "concordance", "load_demo_text", "__version__", ] diff --git a/src/lexograph/layout/__init__.py b/src/lexograph/layout/__init__.py index 418957c..e3c3555 100644 --- a/src/lexograph/layout/__init__.py +++ b/src/lexograph/layout/__init__.py @@ -1,6 +1,8 @@ """Layout: place ordered units in 2-D or 3-D space (walks, spirals, grids).""" +from lexograph.layout.dispersion import KWIC, kwic, term_offsets from lexograph.layout.linear import linear_layout +from lexograph.layout.recurrence import recurrence_distances, recurrence_matrix from lexograph.layout.spiral import spiral_layout, tangent_angles from lexograph.layout.walk import heading_angles, walk_layout from lexograph.layout.walk3d import walk3d_layout @@ -14,4 +16,9 @@ "spiral_layout", "tangent_angles", "rendered_widths", + "recurrence_distances", + "recurrence_matrix", + "term_offsets", + "kwic", + "KWIC", ] diff --git a/src/lexograph/layout/dispersion.py b/src/lexograph/layout/dispersion.py new file mode 100644 index 0000000..c60993f --- /dev/null +++ b/src/lexograph/layout/dispersion.py @@ -0,0 +1,116 @@ +"""The dispersion grid: where terms fall across a text (and texts/time). + +The concordance preset plots a term's *dispersion* — a different grid from the +recurrence dotplot. Here one axis is position in the text and the other indexes +the terms (or, across a corpus, the texts or time slices); each occurrence is a +tick. This module produces the plain per-term offset arrays the preset draws, +plus keyword-in-context (KWIC) lines. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +import numpy as np + +from lexograph._types import FloatArray +from lexograph.segment.units import tokens + +__all__ = ["term_offsets", "kwic", "KWIC"] + + +def term_offsets( + text: str, + terms: Sequence[str], + *, + ignore_case: bool = True, +) -> dict[str, FloatArray]: + """Return the token offsets at which each term occurs, in order. + + Args: + text: The source text. + terms: The terms to locate (each matched as a whole token). + ignore_case: Match case-insensitively. + + Returns: + A mapping from each input term to a float array of the token indices at + which it occurs (empty if it never does). The mapping preserves the + order of ``terms``. + + Examples: + >>> offsets = term_offsets("the cat and the dog and the cat", ["cat", "dog"]) + >>> offsets["cat"].tolist() + [1.0, 7.0] + >>> offsets["dog"].tolist() + [4.0] + """ + toks = tokens(text) + haystack = [t.lower() for t in toks] if ignore_case else toks + result: dict[str, FloatArray] = {} + for term in terms: + needle = term.lower() if ignore_case else term + positions = [i for i, tok in enumerate(haystack) if tok == needle] + result[term] = np.asarray(positions, dtype=float) + return result + + +@dataclass(frozen=True, slots=True) +class KWIC: + """One keyword-in-context line. + + Attributes: + offset: The token index of the keyword. + left: The context tokens to the left, space-joined. + keyword: The matched token, as it appeared in the text. + right: The context tokens to the right, space-joined. + """ + + offset: int + left: str + keyword: str + right: str + + +def kwic( + text: str, + term: str, + *, + width: int = 5, + ignore_case: bool = True, +) -> list[KWIC]: + """Return keyword-in-context lines for ``term``. + + Args: + text: The source text. + term: The term to find (matched as a whole token). + width: How many context tokens to keep on each side. + ignore_case: Match case-insensitively. + + Returns: + One :class:`KWIC` per occurrence, in text order. + + Raises: + ValueError: If ``width`` is negative. + + Examples: + >>> lines = kwic("the small cat sat on the cat mat", "cat", width=2) + >>> len(lines) + 2 + >>> lines[0].left, lines[0].keyword, lines[0].right + ('the small', 'cat', 'sat on') + """ + if width < 0: + msg = f"width must be non-negative, got {width}" + raise ValueError(msg) + toks = tokens(text) + haystack = [t.lower() for t in toks] if ignore_case else toks + needle = term.lower() if ignore_case else term + lines: list[KWIC] = [] + for i, tok in enumerate(haystack): + if tok != needle: + continue + left = " ".join(toks[max(0, i - width) : i]) + right = " ".join(toks[i + 1 : i + 1 + width]) + lines.append(KWIC(offset=i, left=left, keyword=toks[i], right=right)) + return lines diff --git a/src/lexograph/layout/recurrence.py b/src/lexograph/layout/recurrence.py new file mode 100644 index 0000000..6b5c060 --- /dev/null +++ b/src/lexograph/layout/recurrence.py @@ -0,0 +1,117 @@ +"""The recurrence grid: a text's self-similarity, sentence against sentence. + +The recurrence dotplot is the only layout that plots a text against *itself*. The +units are sentences; cell ``(i, j)`` measures how similar sentence ``i`` is to +sentence ``j``. Thresholding the distances turns the grid into a dotplot whose +lit cells expose internal echo structure — recurring themes, repeated phrasing, +digressions that return. + +The built-in, dependency-free distance is token-set **Jaccard** (or character +n-gram Jaccard), so the grid works with no analysis stack. The ``[graph]`` extra +can supply an embedding distance matrix instead; pass it straight to +:func:`recurrence_matrix` or the preset. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import numpy as np + +from lexograph._types import FloatArray + +__all__ = ["recurrence_distances", "recurrence_matrix"] + + +def _features(units: Sequence[str], shingle: int | None) -> list[frozenset[str]]: + """Build a comparable feature set per unit: word tokens or char n-grams.""" + import re + + token_re = re.compile(r"\w+(?:['’-]\w+)*") + features: list[frozenset[str]] = [] + for unit in units: + lowered = unit.lower() + if shingle is None: + features.append(frozenset(token_re.findall(lowered))) + else: + squeezed = "".join(lowered.split()) + grams = { + squeezed[i : i + shingle] for i in range(len(squeezed) - shingle + 1) + } + features.append(frozenset(grams)) + return features + + +def recurrence_distances( + units: Sequence[str], + *, + shingle: int | None = None, +) -> FloatArray: + """Return the pairwise Jaccard distance matrix between units. + + Args: + units: The sentences (or any strings) to compare against each other. + shingle: If ``None``, compare lowercased word-token sets. If a positive + integer ``k``, compare sets of character ``k``-grams instead (robust + to short or morphologically varied sentences). + + Returns: + An ``(N, N)`` symmetric float array of Jaccard distances in ``[0, 1]`` + with a zero diagonal. Two units with no features in common are at + distance 1; two empty units are at distance 0. + + Contract: + - The matrix is symmetric with a zero diagonal. + - Every entry lies in ``[0, 1]``. + + Examples: + >>> d = recurrence_distances(["the cat sat", "the cat sat", "a dog ran"]) + >>> float(d[0, 1]) + 0.0 + >>> bool(d[0, 2] > 0.9) + True + """ + n = len(units) + features = _features(units, shingle) + dist = np.zeros((n, n), dtype=float) + for i in range(n): + fi = features[i] + for j in range(i + 1, n): + fj = features[j] + union = len(fi | fj) + similarity = (len(fi & fj) / union) if union else 1.0 + dist[i, j] = dist[j, i] = 1.0 - similarity + return dist + + +def recurrence_matrix( + distances: FloatArray, + *, + threshold: float = 0.6, +) -> np.ndarray: + """Threshold a distance matrix into a boolean recurrence (dotplot) grid. + + Args: + distances: An ``(N, N)`` distance matrix (from + :func:`recurrence_distances`, or an embedding distance matrix from + the ``[graph]`` extra). + threshold: Cells with distance ``<= threshold`` are lit (recurrent). + + Returns: + An ``(N, N)`` boolean array; ``True`` marks a recurrent (similar) cell. + The diagonal is always ``True`` (every sentence echoes itself). + + Raises: + ValueError: If ``distances`` is not a square 2-D matrix. + + Examples: + >>> import numpy as np + >>> d = np.array([[0.0, 0.2, 0.9], [0.2, 0.0, 0.8], [0.9, 0.8, 0.0]]) + >>> recurrence_matrix(d, threshold=0.5).tolist() + [[True, True, False], [True, True, False], [False, False, True]] + """ + array = np.asarray(distances, dtype=float) + if array.ndim != 2 or array.shape[0] != array.shape[1]: + msg = f"distances must be a square (N, N) matrix, got {array.shape}" + raise ValueError(msg) + return array <= threshold diff --git a/src/lexograph/presets/__init__.py b/src/lexograph/presets/__init__.py index 6a93810..48deb98 100644 --- a/src/lexograph/presets/__init__.py +++ b/src/lexograph/presets/__init__.py @@ -1,6 +1,14 @@ """Presets: ready-made points on the segment-layout-encode-render spine.""" +from lexograph.presets.concordance import concordance from lexograph.presets.punctuation_spiral import is_accent, punctuation_spiral +from lexograph.presets.recurrence import recurrence_plot from lexograph.presets.text_walk import text_walk -__all__ = ["punctuation_spiral", "is_accent", "text_walk"] +__all__ = [ + "punctuation_spiral", + "is_accent", + "text_walk", + "recurrence_plot", + "concordance", +] diff --git a/src/lexograph/presets/concordance.py b/src/lexograph/presets/concordance.py new file mode 100644 index 0000000..db91880 --- /dev/null +++ b/src/lexograph/presets/concordance.py @@ -0,0 +1,94 @@ +"""Concordance preset: a term's dispersion across the text, with optional KWIC. + +For each term, every occurrence is a tick at its token offset; the terms stack on +the vertical axis. This is the classic lexical-dispersion plot — a *term × text* +grid, distinct from the recurrence dotplot's *sentence × sentence* self-similarity. +Keyword-in-context (KWIC) lines for any term are available from +:func:`lexograph.layout.dispersion.kwic`. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from matplotlib.figure import Figure + +from lexograph.encode.channels import categorical_colors +from lexograph.layout.dispersion import term_offsets +from lexograph.segment.units import tokens + +__all__ = ["concordance"] + + +def concordance( + text: str, + terms: Sequence[str], + *, + ignore_case: bool = True, + normalize: bool = False, + figsize: tuple[float, float] | None = None, + background: str = "white", +) -> Figure: + """Draw a lexical-dispersion plot of where each term falls in the text. + + Args: + text: The source text. + terms: The terms to plot, one row each (top to bottom in this order). + ignore_case: Match terms case-insensitively. + normalize: If ``True``, scale the x-axis to ``[0, 1]`` (fraction of the + text) instead of absolute token offset. + figsize: Figure size in inches. Defaults to a height that grows with the + number of terms. + background: Figure and axes background colour. + + Returns: + A :class:`matplotlib.figure.Figure` with one axes. Never calls ``show()``. + + Raises: + ValueError: If ``terms`` is empty. + + Contract: + - Returns a Figure with exactly one axes. + - There is exactly one y-row per term, in the given order. + + Examples: + >>> from lexograph import load_demo_text + >>> fig = concordance(load_demo_text(), ["Bennet", "Bingley", "wife"]) + >>> type(fig).__name__ + 'Figure' + >>> len(fig.axes[0].get_yticks()) + 3 + """ + if len(terms) == 0: + msg = "need at least one term for a concordance plot" + raise ValueError(msg) + + total = len(tokens(text)) + span = float(total) if total else 1.0 + offsets = term_offsets(text, terms, ignore_case=ignore_case) + colours = categorical_colors(list(range(len(terms)))) + + if figsize is None: + figsize = (10.0, 0.6 * len(terms) + 1.5) + fig = Figure(figsize=figsize, facecolor=background) + ax = fig.subplots() + ax.set_facecolor(background) + + for row, term in enumerate(terms): + xs = offsets[term] + if normalize and len(xs): + xs = xs / span + if len(xs): + ax.vlines(xs, row - 0.4, row + 0.4, color=colours[row]) + + ax.set_yticks(range(len(terms))) + ax.set_yticklabels(list(terms)) + ax.set_ylim(-0.5, len(terms) - 0.5) + ax.invert_yaxis() # first term on top + ax.set_xlim(0.0, 1.0 if normalize else span) + ax.set_xlabel( + "position in text" + (" (fraction)" if normalize else " (token offset)") + ) + ax.set_title("Lexical dispersion") + fig.tight_layout() + return fig diff --git a/src/lexograph/presets/recurrence.py b/src/lexograph/presets/recurrence.py new file mode 100644 index 0000000..6935826 --- /dev/null +++ b/src/lexograph/presets/recurrence.py @@ -0,0 +1,104 @@ +"""Recurrence-dotplot preset: a text plotted against itself. + +The text is segmented into sentences; the preset draws the sentence × sentence +self-similarity grid (see :mod:`lexograph.layout.recurrence`) with matplotlib +``imshow``. Lit cells off the main diagonal mark sentences that echo each other — +recurring themes, repeated phrasing, digressions that return. + +The default distance is the dependency-free token/character Jaccard, so the plot +needs no analysis stack; pass a precomputed ``distances`` matrix (e.g. an +embedding distance from the ``[graph]`` extra) to drive it from semantics. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal + +import numpy as np +from matplotlib.figure import Figure + +from lexograph.layout.recurrence import recurrence_distances, recurrence_matrix +from lexograph.segment.units import sentences as split_sentences + +if TYPE_CHECKING: + from lexograph._types import FloatArray + +__all__ = ["recurrence_plot"] + +RecurrenceMode = Literal["binary", "distance"] + + +def recurrence_plot( + text: str, + *, + threshold: float = 0.6, + shingle: int | None = None, + distances: FloatArray | None = None, + mode: RecurrenceMode = "binary", + figsize: tuple[float, float] = (8.0, 8.0), + background: str = "white", +) -> Figure: + """Draw the sentence × sentence recurrence dotplot of a text. + + Args: + text: The source text. + threshold: In ``"binary"`` mode, cells with distance ``<= threshold`` are + lit. + shingle: Passed to :func:`lexograph.layout.recurrence.recurrence_distances` + — ``None`` for word-token Jaccard, or ``k`` for character ``k``-gram + Jaccard. Ignored when ``distances`` is given. + distances: A precomputed ``(N, N)`` distance matrix to use instead of the + built-in Jaccard (e.g. an embedding distance from the ``[graph]`` + extra). Must match the sentence count. + mode: ``"binary"`` draws the thresholded dotplot; ``"distance"`` draws the + full similarity heatmap. + figsize: Figure size in inches. + background: Figure background colour. + + Returns: + A :class:`matplotlib.figure.Figure` with one axes. Never calls ``show()``. + + Raises: + ValueError: If the text has fewer than two sentences, or a supplied + ``distances`` matrix does not match the sentence count. + + Contract: + - Returns a Figure with exactly one axes. + - The grid is square (sentence count on each axis) and symmetric. + + Examples: + >>> from lexograph import load_demo_text + >>> fig = recurrence_plot(load_demo_text()) + >>> type(fig).__name__ + 'Figure' + """ + units = split_sentences(text) + n = len(units) + if n < 2: + msg = f"need at least two sentences for a recurrence plot, got {n}" + raise ValueError(msg) + + if distances is not None: + dist = np.asarray(distances, dtype=float) + if dist.shape != (n, n): + msg = f"distances must have shape ({n}, {n}), got {dist.shape}" + raise ValueError(msg) + else: + dist = recurrence_distances(units, shingle=shingle) + + fig = Figure(figsize=figsize, facecolor=background) + ax = fig.subplots() + ax.set_facecolor(background) + + if mode == "distance": + # Low distance (similar) renders dark. + ax.imshow(dist, cmap="Greys_r", origin="upper", interpolation="nearest") + else: + grid = recurrence_matrix(dist, threshold=threshold) + ax.imshow(grid, cmap="Greys", origin="upper", interpolation="nearest") + + ax.set_xlabel("sentence index") + ax.set_ylabel("sentence index") + ax.set_title("Recurrence dotplot") + fig.tight_layout() + return fig diff --git a/tests/test_dispersion.py b/tests/test_dispersion.py new file mode 100644 index 0000000..20c3361 --- /dev/null +++ b/tests/test_dispersion.py @@ -0,0 +1,52 @@ +import pytest + +from lexograph.layout.dispersion import kwic, term_offsets + + +class TestTermOffsets: + """Term offsets are the token indices where each term occurs.""" + + def test_offsets(self) -> None: + offsets = term_offsets("the cat and the dog and the cat", ["cat", "dog"]) + assert offsets["cat"].tolist() == [1.0, 7.0] + assert offsets["dog"].tolist() == [4.0] + + def test_case_sensitivity(self) -> None: + sensitive = term_offsets("Cat cat CAT", ["cat"], ignore_case=False) + assert sensitive["cat"].tolist() == [1.0] + insensitive = term_offsets("Cat cat CAT", ["cat"], ignore_case=True) + assert insensitive["cat"].tolist() == [0.0, 1.0, 2.0] + + def test_missing_term_is_empty(self) -> None: + offsets = term_offsets("the cat sat", ["dog"]) + assert offsets["dog"].tolist() == [] + + def test_preserves_term_order(self) -> None: + offsets = term_offsets("a b c", ["c", "a", "b"]) + assert list(offsets.keys()) == ["c", "a", "b"] + + +class TestKwic: + """KWIC returns the context tokens around each occurrence.""" + + def test_basic(self) -> None: + lines = kwic("the small cat sat on the cat mat", "cat", width=2) + assert len(lines) == 2 + assert (lines[0].left, lines[0].keyword, lines[0].right) == ( + "the small", + "cat", + "sat on", + ) + + def test_edge_truncates_context(self) -> None: + lines = kwic("cat sat down", "cat", width=3) + assert lines[0].left == "" + assert lines[0].right == "sat down" + + def test_keyword_preserves_original_case(self) -> None: + lines = kwic("The Cat sat", "cat", width=1) + assert lines[0].keyword == "Cat" + + def test_negative_width_raises(self) -> None: + with pytest.raises(ValueError, match="non-negative"): + kwic("the cat", "cat", width=-1) diff --git a/tests/test_presets.py b/tests/test_presets.py index ea13539..94c1a25 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1,7 +1,14 @@ +import numpy as np import pytest from matplotlib.figure import Figure -from lexograph import punctuation_spiral, segment, text_walk +from lexograph import ( + concordance, + punctuation_spiral, + recurrence_plot, + segment, + text_walk, +) from lexograph.presets.punctuation_spiral import is_accent @@ -67,3 +74,56 @@ def test_too_few_sentences_raises(self) -> None: def test_wrong_colour_length_raises(self, demo_text: str) -> None: with pytest.raises(ValueError, match="one entry per sentence"): text_walk(demo_text, colour=[0, 1, 2]) + + +class TestRecurrencePlot: + """The recurrence dotplot plots the text against itself.""" + + def test_returns_figure_with_image(self, demo_text: str) -> None: + fig = recurrence_plot(demo_text) + assert isinstance(fig, Figure) + assert len(fig.axes[0].images) == 1 + + def test_distance_mode(self, demo_text: str) -> None: + fig = recurrence_plot(demo_text, mode="distance") + assert isinstance(fig, Figure) + + def test_character_shingle(self, demo_text: str) -> None: + fig = recurrence_plot(demo_text, shingle=4) + assert isinstance(fig, Figure) + + def test_external_distances(self, demo_text: str) -> None: + n = len(segment(demo_text)) + rng = np.zeros((n, n)) + fig = recurrence_plot(demo_text, distances=rng) + assert isinstance(fig, Figure) + + def test_grid_is_square(self, demo_text: str) -> None: + n = len(segment(demo_text)) + fig = recurrence_plot(demo_text) + assert fig.axes[0].images[0].get_array().shape == (n, n) + + def test_bad_distances_shape_raises(self, demo_text: str) -> None: + with pytest.raises(ValueError, match="must have shape"): + recurrence_plot(demo_text, distances=np.zeros((3, 3))) + + def test_too_few_sentences_raises(self) -> None: + with pytest.raises(ValueError, match="at least two sentences"): + recurrence_plot("One sentence only.") + + +class TestConcordance: + """The concordance plots each term's dispersion as a row of ticks.""" + + def test_returns_figure(self, demo_text: str) -> None: + fig = concordance(demo_text, ["Bennet", "Bingley", "wife"]) + assert isinstance(fig, Figure) + assert len(fig.axes[0].get_yticks()) == 3 + + def test_normalize(self, demo_text: str) -> None: + fig = concordance(demo_text, ["Bennet"], normalize=True) + assert fig.axes[0].get_xlim() == (0.0, 1.0) + + def test_empty_terms_raises(self, demo_text: str) -> None: + with pytest.raises(ValueError, match="at least one term"): + concordance(demo_text, []) diff --git a/tests/test_recurrence.py b/tests/test_recurrence.py new file mode 100644 index 0000000..004d085 --- /dev/null +++ b/tests/test_recurrence.py @@ -0,0 +1,57 @@ +import numpy as np +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from lexograph.layout.recurrence import recurrence_distances, recurrence_matrix + +sentence_lists = st.lists( + st.text(alphabet="abcde ", min_size=0, max_size=20), + min_size=1, + max_size=12, +) + + +class TestRecurrenceDistances: + """Jaccard self-distance is symmetric, zero-diagonal, and in [0, 1].""" + + def test_identical_sentences_distance_zero(self) -> None: + d = recurrence_distances(["the cat sat", "the cat sat"]) + assert d[0, 1] == 0.0 + + def test_disjoint_sentences_distance_one(self) -> None: + d = recurrence_distances(["alpha beta", "gamma delta"]) + assert d[0, 1] == 1.0 + + def test_character_shingle(self) -> None: + d = recurrence_distances(["running", "runner"], shingle=3) + assert 0.0 < d[0, 1] < 1.0 + + @given(sentence_lists) + def test_symmetric_zero_diagonal_bounded(self, units: list[str]) -> None: + d = recurrence_distances(units) + assert np.allclose(d, d.T) + assert np.allclose(np.diag(d), 0.0) + assert np.all((d >= 0.0) & (d <= 1.0)) + + +class TestRecurrenceMatrix: + """Thresholding lights similar cells; the diagonal is always on.""" + + def test_threshold(self) -> None: + d = np.array([[0.0, 0.2, 0.9], [0.2, 0.0, 0.8], [0.9, 0.8, 0.0]]) + grid = recurrence_matrix(d, threshold=0.5) + assert grid.tolist() == [ + [True, True, False], + [True, True, False], + [False, False, True], + ] + + def test_diagonal_always_on(self) -> None: + d = recurrence_distances(["a b", "c d", "e f"]) + grid = recurrence_matrix(d, threshold=0.0) + assert np.all(np.diag(grid)) + + def test_non_square_raises(self) -> None: + with pytest.raises(ValueError, match="square"): + recurrence_matrix(np.zeros((2, 3)))