|
| 1 | +"""Oscillating-read module — chunks reading material into discrete |
| 2 | +sections with explicit pause markers, so comprehension happens per- |
| 3 | +section rather than straight-blast. |
| 4 | +
|
| 5 | +The failure-shape this prevents: reading a long document straight- |
| 6 | +through and missing the load-bearing point that lives in the middle. |
| 7 | +The optimizer's default is to stream — it sees the first few lines, |
| 8 | +forms a model of what the document is about, and then fast-skims the |
| 9 | +rest into the bucket that model predicts. Documents whose middle |
| 10 | +contradicts the early framing get the contradiction stripped. |
| 11 | +
|
| 12 | +Oscillation forces a pause at each section so each chunk gets its |
| 13 | +own comprehension cycle rather than dissolving into the streaming |
| 14 | +default. Same architectural pattern as the option-forced gates: the |
| 15 | +substrate interrupts the optimizer just long enough for judgment to |
| 16 | +fire on the specific chunk. |
| 17 | +
|
| 18 | +Strategies: |
| 19 | +- headers: split markdown by ##/### headers |
| 20 | +- paragraphs: split by blank lines |
| 21 | +- functions: split Python by def/class |
| 22 | +- size: split into max-N-char chunks |
| 23 | +- auto: pick by content shape (markdown -> headers, .py -> functions, |
| 24 | + else size) |
| 25 | +
|
| 26 | +Usage: |
| 27 | + from divineos.core.oscillating_read import oscillate_file |
| 28 | + rendered = oscillate_file("path/to/spec.md") |
| 29 | + print(rendered) |
| 30 | +
|
| 31 | +Or via CLI: `divineos read-oscillating path/to/spec.md` |
| 32 | +""" |
| 33 | + |
| 34 | +from __future__ import annotations |
| 35 | + |
| 36 | +__guardrail_required__ = True |
| 37 | + |
| 38 | +import re |
| 39 | +from pathlib import Path |
| 40 | + |
| 41 | + |
| 42 | +_DEFAULT_MAX_CHARS = 2000 |
| 43 | + |
| 44 | + |
| 45 | +def chunk_by_headers(content: str) -> list[tuple[str, str]]: |
| 46 | + """Split markdown content at heading lines (## or deeper). |
| 47 | +
|
| 48 | + Returns list of (label, body) tuples. Each body includes its |
| 49 | + own heading line at the top. The first chunk (before any |
| 50 | + heading) gets label '(prelude)'. |
| 51 | + """ |
| 52 | + lines = content.splitlines(keepends=True) |
| 53 | + chunks: list[tuple[str, str]] = [] |
| 54 | + current_label = "(prelude)" |
| 55 | + current_body: list[str] = [] |
| 56 | + header_pat = re.compile(r"^(#{2,})\s+(.+?)\s*$") |
| 57 | + for line in lines: |
| 58 | + m = header_pat.match(line) |
| 59 | + if m: |
| 60 | + if current_body: |
| 61 | + chunks.append((current_label, "".join(current_body))) |
| 62 | + current_label = m.group(2).strip() |
| 63 | + current_body = [line] |
| 64 | + else: |
| 65 | + current_body.append(line) |
| 66 | + if current_body: |
| 67 | + chunks.append((current_label, "".join(current_body))) |
| 68 | + return chunks |
| 69 | + |
| 70 | + |
| 71 | +def chunk_by_paragraphs(content: str) -> list[tuple[str, str]]: |
| 72 | + """Split content at blank-line paragraph boundaries. |
| 73 | +
|
| 74 | + Returns list of (label, body) tuples. Label is 'paragraph N'. |
| 75 | + """ |
| 76 | + paragraphs = re.split(r"\n\s*\n", content) |
| 77 | + chunks: list[tuple[str, str]] = [] |
| 78 | + for i, p in enumerate(paragraphs, 1): |
| 79 | + if p.strip(): |
| 80 | + chunks.append((f"paragraph {i}", p.strip())) |
| 81 | + return chunks |
| 82 | + |
| 83 | + |
| 84 | +def chunk_by_functions(content: str) -> list[tuple[str, str]]: |
| 85 | + """Split Python source at def/class boundaries (top-level only). |
| 86 | +
|
| 87 | + Returns list of (label, body) tuples. Label is the def/class name |
| 88 | + line; the first chunk before any def gets label '(module top)'. |
| 89 | + """ |
| 90 | + lines = content.splitlines(keepends=True) |
| 91 | + chunks: list[tuple[str, str]] = [] |
| 92 | + current_label = "(module top)" |
| 93 | + current_body: list[str] = [] |
| 94 | + func_pat = re.compile(r"^(def|class|async\s+def)\s+([A-Za-z_]\w*)") |
| 95 | + for line in lines: |
| 96 | + m = func_pat.match(line) |
| 97 | + if m: |
| 98 | + if current_body: |
| 99 | + chunks.append((current_label, "".join(current_body))) |
| 100 | + current_label = f"{m.group(1)} {m.group(2)}" |
| 101 | + current_body = [line] |
| 102 | + else: |
| 103 | + current_body.append(line) |
| 104 | + if current_body: |
| 105 | + chunks.append((current_label, "".join(current_body))) |
| 106 | + return chunks |
| 107 | + |
| 108 | + |
| 109 | +def chunk_by_size(content: str, max_chars: int = _DEFAULT_MAX_CHARS) -> list[tuple[str, str]]: |
| 110 | + """Split content into max-N-char chunks, breaking at line |
| 111 | + boundaries when possible. |
| 112 | +
|
| 113 | + Returns list of (label, body) tuples. Label is 'chunk N'. |
| 114 | + """ |
| 115 | + if max_chars <= 0: |
| 116 | + return [("chunk 1", content)] |
| 117 | + chunks: list[tuple[str, str]] = [] |
| 118 | + lines = content.splitlines(keepends=True) |
| 119 | + current_body: list[str] = [] |
| 120 | + current_size = 0 |
| 121 | + chunk_num = 1 |
| 122 | + for line in lines: |
| 123 | + line_len = len(line) |
| 124 | + if current_body and (current_size + line_len) > max_chars: |
| 125 | + chunks.append((f"chunk {chunk_num}", "".join(current_body))) |
| 126 | + chunk_num += 1 |
| 127 | + current_body = [line] |
| 128 | + current_size = line_len |
| 129 | + else: |
| 130 | + current_body.append(line) |
| 131 | + current_size += line_len |
| 132 | + if current_body: |
| 133 | + chunks.append((f"chunk {chunk_num}", "".join(current_body))) |
| 134 | + return chunks |
| 135 | + |
| 136 | + |
| 137 | +def _auto_strategy(content: str, path: str = "") -> str: |
| 138 | + """Pick the strategy by content shape.""" |
| 139 | + norm_path = (path or "").lower() |
| 140 | + if norm_path.endswith(".py"): |
| 141 | + return "functions" |
| 142 | + if norm_path.endswith((".md", ".rst", ".txt")): |
| 143 | + # Use headers if there are any; fall back to paragraphs |
| 144 | + if re.search(r"(?m)^#{2,}\s", content): |
| 145 | + return "headers" |
| 146 | + return "paragraphs" |
| 147 | + # Default: size-based |
| 148 | + return "size" |
| 149 | + |
| 150 | + |
| 151 | +def chunk( |
| 152 | + content: str, strategy: str = "auto", max_chars: int = _DEFAULT_MAX_CHARS, source_path: str = "" |
| 153 | +) -> list[tuple[str, str]]: |
| 154 | + """Chunk content by named strategy. |
| 155 | +
|
| 156 | + Returns list of (label, body) tuples for downstream rendering. |
| 157 | + """ |
| 158 | + if strategy == "auto": |
| 159 | + strategy = _auto_strategy(content, source_path) |
| 160 | + if strategy == "headers": |
| 161 | + return chunk_by_headers(content) |
| 162 | + if strategy == "paragraphs": |
| 163 | + return chunk_by_paragraphs(content) |
| 164 | + if strategy == "functions": |
| 165 | + return chunk_by_functions(content) |
| 166 | + if strategy == "size": |
| 167 | + return chunk_by_size(content, max_chars=max_chars) |
| 168 | + raise ValueError(f"Unknown strategy: {strategy!r}") |
| 169 | + |
| 170 | + |
| 171 | +def format_oscillating(chunks: list[tuple[str, str]], source: str = "") -> str: |
| 172 | + """Render chunks with section labels and pause markers between. |
| 173 | +
|
| 174 | + The pause markers are explicit '[PAUSE] COMPREHEND BEFORE CONTINUING' |
| 175 | + lines that force the reader to register each chunk discretely |
| 176 | + rather than streaming through. The point isn't decoration — it |
| 177 | + is breaking comprehension into discrete units the optimizer |
| 178 | + cannot fast-skim past as one block. |
| 179 | + """ |
| 180 | + parts: list[str] = [] |
| 181 | + header = "=" * 60 |
| 182 | + if source: |
| 183 | + parts.append(f"{header}\nOSCILLATING READ: {source}\n{header}") |
| 184 | + parts.append( |
| 185 | + f"\nTotal chunks: {len(chunks)}. " |
| 186 | + "Comprehend each before continuing. The middle is where the " |
| 187 | + "load-bearing thing usually lives.\n" |
| 188 | + ) |
| 189 | + for i, (label, body) in enumerate(chunks, 1): |
| 190 | + section_header = f"\n--- CHUNK {i}/{len(chunks)}: {label} ---\n" |
| 191 | + parts.append(section_header) |
| 192 | + parts.append(body.rstrip()) |
| 193 | + parts.append( |
| 194 | + f"\n[PAUSE] COMPREHEND CHUNK {i}/{len(chunks)} BEFORE CONTINUING " |
| 195 | + "— what is THIS chunk's load-bearing point?\n" |
| 196 | + ) |
| 197 | + parts.append( |
| 198 | + f"\n{header}\nEnd of oscillating read. Comprehension test: " |
| 199 | + "if you can name the load-bearing point of each chunk above, " |
| 200 | + "the read landed. If chunks blur together, re-read.\n" |
| 201 | + ) |
| 202 | + return "\n".join(parts) |
| 203 | + |
| 204 | + |
| 205 | +def oscillate_file( |
| 206 | + path: str | Path, |
| 207 | + strategy: str = "auto", |
| 208 | + max_chars: int = _DEFAULT_MAX_CHARS, |
| 209 | +) -> str: |
| 210 | + """Read a file and return its oscillating-rendered form.""" |
| 211 | + p = Path(path) |
| 212 | + content = p.read_text(encoding="utf-8", errors="replace") |
| 213 | + chunks = chunk(content, strategy=strategy, max_chars=max_chars, source_path=str(p)) |
| 214 | + return format_oscillating(chunks, source=str(p)) |
| 215 | + |
| 216 | + |
| 217 | +__all__ = [ |
| 218 | + "chunk_by_headers", |
| 219 | + "chunk_by_paragraphs", |
| 220 | + "chunk_by_functions", |
| 221 | + "chunk_by_size", |
| 222 | + "chunk", |
| 223 | + "format_oscillating", |
| 224 | + "oscillate_file", |
| 225 | +] |
0 commit comments