Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions docs/api/concordance.md
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions docs/api/recurrence.md
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions docs/examples/concordance.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions docs/examples/recurrence.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions examples/concordance_demo.py
Original file line number Diff line number Diff line change
@@ -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()
18 changes: 18 additions & 0 deletions examples/recurrence_demo.py
Original file line number Diff line number Diff line change
@@ -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()
4 changes: 4 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion src/lexograph/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -35,6 +42,8 @@
"walk3d_layout",
"spiral_layout",
"rendered_widths",
"term_offsets",
"kwic",
"normalize_size",
"categorical_colors",
"continuous_colors",
Expand All @@ -45,6 +54,8 @@
"frame_axes",
"punctuation_spiral",
"text_walk",
"recurrence_plot",
"concordance",
"load_demo_text",
"__version__",
]
7 changes: 7 additions & 0 deletions src/lexograph/layout/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -14,4 +16,9 @@
"spiral_layout",
"tangent_angles",
"rendered_widths",
"recurrence_distances",
"recurrence_matrix",
"term_offsets",
"kwic",
"KWIC",
]
116 changes: 116 additions & 0 deletions src/lexograph/layout/dispersion.py
Original file line number Diff line number Diff line change
@@ -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
Loading