hv-writhe-stories
Narrative writhe: a topological invariant for stories. Encode a story as a sequence of signed beats. The sum is the writhe W. Writhe mod 2 is preserved by narrative R2 and R3 moves, and broken by R1. Applied to ten canonical narratives.
Size: ~20 KB source, no weights. Runtime: <1 s for the full benchmark. Dependencies: NumPy only.
The primitive
A story is a sequence of beats, each with a valence in {β2, β¦, +2}:
| valence | interpretation |
|---|---|
| +2 | strong positive turn toward the protagonist's goal |
| +1 | small positive turn |
| 0 | neutral / transitional |
| β1 | small negative turn |
| β2 | strong negative turn (loss, betrayal, death) |
Writhe W = Ξ£ valence(beat_k) β the sum.
Trajectory T_k = Ξ£_{jβ€k} valence(beat_j) β the protagonist's fortune over time.
Sign class: comedic (W > 0), tragic (W < 0), balanced (W = 0).
Mod-2 class: W mod 2. A single-bit invariant.
The three Reidemeister moves on stories
| move | operation | effect on W | effect on mod-2 |
|---|---|---|---|
| R1 | insert one beat with valence d | W β W + d |
flips iff d odd |
| R2 | insert a matched pair (+m, βm) | W β W |
preserved |
| R3 | swap two non-adjacent beats | W β W |
preserved |
Proposition. Writhe mod 2 is invariant under R2 and R3, and changes
by d mod 2 under R1 with valence d.
The moves have narrative interpretations:
- R1 = add a self-contained beat that changes the outcome (a late triumph or tragedy).
- R2 = add a false alarm: a setback immediately reversed.
- R3 = reorder non-adjacent beats (non-chronological retelling).
R2 and R3 preserve writhe. R1 preserves mod-2 only for even valences.
Headline numbers
Ten canonical narratives, hand-encoded:
| story | genre | beats | W | mod-2 | class |
|---|---|---|---|---|---|
| Oedipus Rex | tragedy | 6 | β6 | 0 | tragic |
| Hamlet | tragedy | 6 | β7 | 1 | tragic |
| Romeo and Juliet | tragedy | 5 | β3 | 1 | tragic |
| Breaking Bad | tragedy | 6 | β7 | 1 | tragic |
| Pride and Prejudice | comedy | 5 | +2 | 0 | comedic |
| Fleabag | comedy | 5 | 0 | 0 | balanced |
| Hero's Journey | myth | 7 | β1 | 1 | tragic |
| The Lord of the Rings | myth | 6 | β2 | 0 | tragic |
| The Matrix | myth | 6 | β3 | 1 | tragic |
| Waiting for Godot | absurdist | 6 | 0 | 0 | balanced |
80/80 consistency checks pass.
The genre-label mismatch
Seven of ten stories have sign classes matching their genre labels. The three mismatches are informative:
Mythic reads as tragic. Hero's Journey (W = β1), The Lord of the Rings (W = β2), and The Matrix (W = β3) all have negative writhe. The hero's journey ends with a boon, but the trials dominate the sum. The monomyth is about surviving loss, not about accumulating gain.
Fleabag reads as balanced. Its ending is bittersweet β the protagonist loses the Priest but gains self-acceptance. W = 0. Genre labels call it comedy because it is funny, not because it ends in triumph.
The interpretive claim:
Genre labels describe how a work is received and sold. Writhe describes how its beats are arranged. The two correlate (7/10 in this corpus) but are not identical. The mismatches are the most interesting cases, because they reveal that a work's structural trajectory can differ from its marketing category.
Mod-2 classes
The corpus partitions into four mod-2 / sign-class combinations:
| sign class | mod-2 | stories |
|---|---|---|
| comedic | 0 | Pride and Prejudice |
| balanced | 0 | Fleabag, Waiting for Godot |
| tragic | 0 | Oedipus Rex, The Lord of the Rings |
| tragic | 1 | Hamlet, Breaking Bad, Romeo and Juliet, Hero's Journey, The Matrix |
Note: tragedy splits across two mod-2 classes. Oedipus Rex has W = β6 (even); Hamlet has W = β7 (odd). The mod-2 invariant captures a structural parity that genre labels do not.
How to use
from hv_writhe_stories import Beat, Story
# Encode a story
story = Story(
name='My_Story',
beats=[
Beat(-1, 'ordinary life, restless'),
Beat(-2, 'inciting rupture'),
Beat(+1, 'training, awakening'),
Beat(-1, 'betrayal'),
Beat(-2, 'near defeat'),
Beat(+2, 'resurrection'),
],
)
# Read the writhe
print(story.writhe()) # 2 - 6 + 2 - 1 + 1 - 1 = ...
print(story.mod2()) # 0 or 1
print(story.sign_class()) # 'comedic' | 'tragic' | 'balanced'
# Apply Reidemeister moves
story_r1 = story.apply_R1(position=2, direction=+1) # flips mod-2
story_r2 = story.apply_R2(position=3, magnitude=1) # preserves
story_r3 = story.apply_R3(i=0, j=3) # preserves
# Visualize the trajectory
print(story.ascii_plot(width=60, height=10))
- Downloads last month
- 23