3 Commits
Author SHA1 Message Date
ChemaVXandClaude Opus 5 4099e3eecb docs(short): el ejemplo que rompe una regla enseña a romperla
Build & Deploy ResearchOwl / build-and-push (push) Successful in 1m18s
Segunda vez que aparece la misma lección sobre el ejemplo del prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 20:53:13 +00:00
ChemaVXandClaude Opus 5 02e553fffa fix(short): el divisor del prompt iba redondeado y no reproducía su propia cuenta
12 ÷ 2.8 no da 5.1, da 5.0. La cuenta trabajada la hace el modelo con el número
que ve, así que el número que ve tiene que ser el real.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 20:41:02 +00:00
ChemaVXandClaude Opus 5 8000ef2145 fix(short): el prompt pedía tres cosas que no podían cumplirse a la vez
El modelo declaraba menos segundos de los que su propia línea necesitaba en 3 o
4 de cada 5 planos narrados — entre 3,8 y 8,2 s de deriva por Short. El vídeo
salía con la duración correcta porque shortsmith estira, pero el ritmo visual
que el spec escribía no era el que se renderizaba.

No era pereza del modelo: era un juego de reglas insatisfacible. El prompt pedía
a la vez líneas de hasta 18 palabras, planos de 6 s como mucho, y tiempo
declarado suficiente para la propia voz. 18 palabras piden 7,3 s, así que las
tres juntas son imposibles y el modelo rompía la única que nadie comprobaba.

Tres capas, las tres deterministas y sin gastar una generación:

- La regla enseñada llevaba el mismo error de clase que el estimador tenía antes
  del 2026-08-12: palabras por segundo a secas, sin el término de las pausas.
  Medida contra las 28 líneas que el bot ha narrado de verdad, `words/2.75 + 0.5`
  se quedaba corta en 14 y hasta 2,27 s — obedecerla al pie de la letra seguía
  infradeclarando. Ahora es de dos términos, como la voz, y el peor caso baja a
  1,27 s en 5 de 28.
- El tope de palabras por línea se DERIVA del plano más largo (`max_words_in`)
  en vez de escribirse a mano. La contradicción no puede volver.
- El ejemplo de referencia incumplía su propia regla en 2 de sus 6 líneas, y el
  ejemplo es la señal más fuerte del prompt: le estábamos enseñando el fallo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 20:38:51 +00:00
5 changed files with 186 additions and 29 deletions
+18
View File
@@ -97,6 +97,24 @@ What actually landed, and where it differs from the plan below:
redacted." costs three quarters of a second that a characters-only model gives away. redacted." costs three quarters of a second that a characters-only model gives away.
Estimates now land within half a second of the three rendered MP4s. The lesson is the Estimates now land within half a second of the three rendered MP4s. The lesson is the
older one restated: a constant taken from one sample is a guess with a decimal point. older one restated: a constant taken from one sample is a guess with a decimal point.
- **The spec writer was under-declaring because the prompt asked for three things that
could not all be true** (2026-08-13). Every narrated spec the bot had written declared
less time than its own narration needed on three or four shots out of five or six —
3.8 to 8.2 seconds of drift per Short. The video still came out the right length,
because `plan()` grows the shot; what was wrong was that the spec described a visual
rhythm that never rendered. The cause was not a lazy model. The prompt asked at once
for lines of up to 18 words, shots of at most 6 seconds, and enough declared time for
the line — and 18 words need 7.3 s, so the set is unsatisfiable. The model broke the
only one of the three that nothing checked. Three fixes, all deterministic and none
costing a generation to find: the taught rule got the per-sentence term it was missing
(`words/2.75 + 0.5` fell short on 14 of the 28 measured lines, by up to 2.27 s — so
even perfect obedience under-declared); the word cap is now *derived* from the shot
cap rather than written by hand, so the contradiction cannot come back; and the worked
example, which violated its own rule on two of its six lines, was cut to obey it. That
last one is the lesson worth keeping: **the example is the strongest signal in the
prompt, so an example that breaks a rule teaches the breakage**, whatever the prose
says. It is the same finding as "el ejemplo del prompt habla, y por eso los specs
vuelven a hablar", arriving a second time.
Original plan, kept for the record: Original plan, kept for the record:
+2 -2
View File
@@ -139,7 +139,7 @@
"caption": "CONTACT HOLDS RELATIVE POSITION", "caption": "CONTACT HOLDS RELATIVE POSITION",
"turn_deg": 360 "turn_deg": 360
}, },
"narration": "He tried to shake it. Full circle, steep descent, and it was still there." "narration": "He tried to shake it. Full circle, steep descent, and it stayed there."
}, },
{ {
"template": "signal_strips", "template": "signal_strips",
@@ -207,7 +207,7 @@
"url": "THEEXCLUSIONZONE.COM", "url": "THEEXCLUSIONZONE.COM",
"show_mark": true "show_mark": true
}, },
"narration": "The file was never closed. It was filed, and left where anyone can read it." "narration": "The file was never closed. It was left where anyone can read it."
} }
] ]
} }
+42 -20
View File
@@ -23,7 +23,8 @@ import structlog
from src.generator.spec_contract import ( from src.generator.spec_contract import (
SpecInvalid, describe_templates, editorial_notes, estimated_duration, SpecInvalid, describe_templates, editorial_notes, estimated_duration,
validate_spec, NARRATION_WORDS_PER_SECOND, max_words_in, validate_spec, NARRATION_ROUNDED_PAD,
NARRATION_SENTENCE_SILENCE, NARRATION_WORDS_PER_SECOND,
TARGET_MAX_DURATION, TARGET_MIN_DURATION, TARGET_MAX_DURATION, TARGET_MIN_DURATION,
) )
@@ -49,20 +50,26 @@ NOTE_ATTEMPTS = 1
#: El ejemplo de referencia habla 74. La sesión 167 habló 97 y salió a 47,5 s. #: El ejemplo de referencia habla 74. La sesión 167 habló 97 y salió a 47,5 s.
NARRATION_WORD_BUDGET = 80 NARRATION_WORD_BUDGET = 80
#: Lo que mide una línea. No es preferencia de estilo: son las líneas del #: Lo que dura un plano como mucho. Del ejemplo (6,0 s el más largo, 5,6 de
#: ejemplo (11, 12, 10, 14, 12, 15 palabras). El tope anterior — "menos de 25" — #: media). Sin este tope el modelo declaraba 42 s en seis planos de siete
#: no describía nada que el canal hubiera publicado, y el modelo escribió líneas
#: de 25 y 27 palabras sin saltarse ninguna regla.
NARRATION_WORDS_PER_LINE = 12
NARRATION_WORDS_PER_LINE_MAX = 18
#: Lo que dura un plano como mucho. También del ejemplo (6,0 s el más largo,
#: 5,6 de media). Sin este tope el modelo declaraba 42 s en seis planos de siete
#: segundos y LUEGO les colgaba la narración encima: el primer borrador salía a #: segundos y LUEGO les colgaba la narración encima: el primer borrador salía a
#: 50 s las tres veces que se midió, y hacía falta una reescritura entera para #: 50 s las tres veces que se midió, y hacía falta una reescritura entera para
#: bajarlo. #: bajarlo.
MAX_SHOT_DURATION = 6.0 MAX_SHOT_DURATION = 6.0
#: Lo que mide una línea. No es preferencia de estilo: es la media del ejemplo.
NARRATION_WORDS_PER_LINE = 12
#: Y el tope duro NO se escribe a mano: es cuántas palabras caben en el plano
#: más largo que se permite declarar. Escribirlos por separado fue el fallo que
#: hizo que el modelo infradeclarase casi todos sus planos, y no por pereza: el
#: prompt le pedía a la vez líneas de hasta 18 palabras, planos de 6 s como
#: mucho y tiempo declarado suficiente para su propia voz. Las tres a la vez son
#: imposibles — 18 palabras piden 7,3 s — así que el modelo rompía la única que
#: nadie comprobaba, la duración declarada. Derivando el tope de la duración, la
#: contradicción no puede volver.
NARRATION_WORDS_PER_LINE_MAX = max_words_in(MAX_SHOT_DURATION, sentences=2)
EXAMPLE_PATH = Path(__file__).parent / "examples" / "jal1628.json" EXAMPLE_PATH = Path(__file__).parent / "examples" / "jal1628.json"
__all__ = ["ShortSpecWriter", "SpecResult", "SpecWriteFailed", "NARRATIVE_SHAPES"] __all__ = ["ShortSpecWriter", "SpecResult", "SpecWriteFailed", "NARRATIVE_SHAPES"]
@@ -156,11 +163,12 @@ aloud by the renderer and burned in as captions. Write it for the ear.
watches the rest, and the opening shot's narration is those two seconds. Lead \ watches the rest, and the opening shot's narration is those two seconds. Lead \
with the strangest true thing you have, not with a preamble. with the strangest true thing you have, not with a preamble.
- **Keep a line to {words_per_line} words, hard stop at {words_per_line_max}.** \ - **Keep a line to {words_per_line} words, hard stop at {words_per_line_max}.** \
That is the example's own average, and it is not a style preference: a 25-word \ That is the example's own average, and the hard stop is not a style preference \
line is three seconds of your whole budget spent on one shot. Long sentences lose the listener and \ either — it is exactly as much as fits in the longest shot you are allowed to \
stretch the shot; the renderer will not cut your voice off, it will make the \ declare. {words_per_line_max} words is {max_shot:.0f} seconds; the same limit, \
shot longer instead, and a Short that drifts past {target_max:.0f} seconds is a \ written twice. Go past it and the shot has to grow, because the renderer will \
Short people leave. not cut your voice off — it makes the shot longer instead, and a Short that \
drifts past {target_max:.0f} seconds is a Short people leave.
- **Do not read the screen aloud.** The captions already show your words and \ - **Do not read the screen aloud.** The captions already show your words and \
the template already shows its own. If the shot draws "35,000 FT", the voice \ the template already shows its own. If the shot draws "35,000 FT", the voice \
says what that altitude meant, not the number again. says what that altitude meant, not the number again.
@@ -175,13 +183,25 @@ competing with words already on the frame. Chosen silence is an edit; a spec \
with one narrated shot out of eight is not a Short with a voice, it is a Short \ with one narrated shot out of eight is not a Short with a voice, it is a Short \
that forgot to speak. that forgot to speak.
- **Give every narrated shot enough time for its own line, and work it out \ - **Give every narrated shot enough time for its own line, and work it out \
rather than guessing.** The voice reads about {words_per_second:.1f} words a \ rather than guessing.** The voice reads about {words_per_second:g} words a \
second and pauses a quarter second at every full stop, so: second and pauses a quarter second at every full stop, so **count the words AND \
count the sentences**:
duration ≥ words ÷ {words_per_second:.1f} + half a second duration ≥ words ÷ {words_per_second:g} + {sentence_pause} × sentences + \
{rounded_pad}
A twelve-word line needs five seconds; give that shot 5.0, not 4.0. This is \ The second term is the one that catches people out. "Witness identities. \
the one rule that makes your own arithmetic true: a shot runs for the LONGER of \ Sensor details. Locations redacted." is six words and three full stops: it is \
not a fast line, it is three quarters of a second of silence on top. Two lines \
of the same length do not take the same time if one of them is chopped.
Worked, on the example below: shot 1 speaks twelve words in one sentence, so \
12 ÷ {words_per_second:g} + {sentence_pause} + {rounded_pad} = 5.1, and it \
declares 5.5. Shot 4 speaks thirteen words in two sentences, so 13 ÷ \
{words_per_second:g} + 0.5 + {rounded_pad} = 5.7, and it declares 6.0. Round \
up, never down.
This is the one rule that makes your own arithmetic true: a shot runs for the LONGER of \
its declared duration and its line — never shorter, the voice is never cut off \ its declared duration and its line — never shorter, the voice is never cut off \
— so a shot that declares less than its line silently grows, and the video ends \ — so a shot that declares less than its line silently grows, and the video ends \
up longer than the durations you wrote. Hold this rule and the total you \ up longer than the durations you wrote. Hold this rule and the total you \
@@ -466,6 +486,8 @@ class ShortSpecWriter:
words_per_line=NARRATION_WORDS_PER_LINE, words_per_line=NARRATION_WORDS_PER_LINE,
words_per_line_max=NARRATION_WORDS_PER_LINE_MAX, words_per_line_max=NARRATION_WORDS_PER_LINE_MAX,
max_shot=MAX_SHOT_DURATION, max_shot=MAX_SHOT_DURATION,
sentence_pause=f"{NARRATION_SENTENCE_SILENCE:g}",
rounded_pad=f"{NARRATION_ROUNDED_PAD:g}",
example=_load_example(), example=_load_example(),
article=article, article=article,
context=context, context=context,
+46
View File
@@ -29,6 +29,9 @@ __all__ = [
"editorial_notes", "editorial_notes",
"estimated_duration", "estimated_duration",
"spoken_seconds", "spoken_seconds",
"sentence_count",
"teachable_seconds",
"max_words_in",
"describe_templates", "describe_templates",
"TARGET_MIN_DURATION", "TARGET_MIN_DURATION",
"TARGET_MAX_DURATION", "TARGET_MAX_DURATION",
@@ -350,11 +353,54 @@ NARRATION_PAD = 0.45
#: le pide que recorte texto, no tiempo. #: le pide que recorte texto, no tiempo.
NARRATION_WORDS_PER_SECOND = 2.75 NARRATION_WORDS_PER_SECOND = 2.75
#: El respiro redondeado hacia arriba, para la regla que se le enseña al modelo.
#: `NARRATION_PAD` son 0,45 s; "medio segundo" se sostiene en la cabeza y va
#: sobrado, que es la dirección correcta en la que equivocarse.
NARRATION_ROUNDED_PAD = 0.5
#: Final de frase: un punto pegado a la palabra y seguido de espacio o de nada. #: Final de frase: un punto pegado a la palabra y seguido de espacio o de nada.
#: El decimal de "1.5" no cuenta, y por eso mira lo que va detrás. #: El decimal de "1.5" no cuenta, y por eso mira lo que va detrás.
_SENTENCE_END = re.compile(r"[.!?](?=\s|$)") _SENTENCE_END = re.compile(r"[.!?](?=\s|$)")
def sentence_count(line: str) -> int:
"""Frases de una línea, contadas como las cuenta Piper para sus pausas."""
return max(1, len(_SENTENCE_END.findall(line))) if line.strip() else 0
def teachable_seconds(words: int, sentences: int = 1) -> float:
"""Lo que hay que DECLARAR para una línea, en las unidades que el modelo cuenta.
Es `spoken_seconds` traducido de caracteres a palabras. La traducción hace
falta porque un LLM no cuenta caracteres de fiar, pero cuenta palabras y
puntos y la regla tiene que ser computable por quien debe obedecerla, o no
es una regla, es un deseo.
Los dos términos son los mismos que los de la voz. La versión anterior del
prompt colapsaba el segundo en un "+ medio segundo" fijo, y ese es el mismo
error de clase que tenía el estimador antes del 2026-08-12: sin pausa por
frase, una línea troceada en frases cortas se da por rápida. Medido contra
las 28 líneas reales, aquella regla se quedaba corta en 14 y hasta 2,27 s
o sea que un modelo que la obedeciera al pie de la letra seguiría
infradeclarando la mitad de sus planos. Con el término por frase el peor
caso baja a 1,27 s y sólo en 5 de 28.
"""
return (words / NARRATION_WORDS_PER_SECOND
+ sentences * NARRATION_SENTENCE_SILENCE
+ NARRATION_ROUNDED_PAD)
def max_words_in(seconds: float, sentences: int = 1) -> int:
"""Cuántas palabras caben en un plano de esa duración, según la regla de arriba.
Existe para que el tope de palabras por línea y el tope de duración de plano
no puedan volver a contradecirse: se deriva uno del otro en vez de escribir
los dos a mano.
"""
room = seconds - sentences * NARRATION_SENTENCE_SILENCE - NARRATION_ROUNDED_PAD
return max(1, int(room * NARRATION_WORDS_PER_SECOND))
def spoken_seconds(line: str) -> float: def spoken_seconds(line: str) -> float:
"""Lo que tarda la voz en decir una línea, sin el respiro final. """Lo que tarda la voz en decir una línea, sin el respiro final.
+78 -7
View File
@@ -134,7 +134,9 @@ def test_the_worked_example_narrates_most_of_its_shots():
def test_the_worked_example_declares_time_for_its_own_narration(): def test_the_worked_example_declares_time_for_its_own_narration():
"""Un plano que se queda corto para su propia voz enseña a infradeclarar: el """Un plano que se queda corto para su propia voz enseña a infradeclarar: el
render no corta la voz, alarga el plano, y el total se va del objetivo.""" render no corta la voz, alarga el plano, y el total se va del objetivo."""
from src.generator.spec_contract import NARRATION_WORDS_PER_SECOND, spoken_seconds from src.generator.spec_contract import (
NARRATION_PAD, sentence_count, spoken_seconds, teachable_seconds,
)
example = json.loads(EXAMPLE.read_text()) example = json.loads(EXAMPLE.read_text())
for i, shot in enumerate(example["shots"]): for i, shot in enumerate(example["shots"]):
@@ -142,10 +144,14 @@ def test_the_worked_example_declares_time_for_its_own_narration():
if not narration: if not narration:
continue continue
# La cuenta que el prompt le pide al modelo, aplicada al ejemplo que le # La cuenta que el prompt le pide al modelo, aplicada al ejemplo que le
# pone delante. Si no cuadran, la regla en prosa pierde. # pone delante. Si no cuadran, la regla en prosa pierde: el ejemplo es
rule = len(narration.split()) / NARRATION_WORDS_PER_SECOND + 0.5 # la señal más fuerte. Dos de estas seis líneas NO cumplían — y ese es
assert shot["duration"] >= rule, f"shot {i} declara menos de lo que habla" # exactamente el defecto que el modelo copiaba.
assert shot["duration"] >= spoken_seconds(narration), \ rule = teachable_seconds(len(narration.split()), sentence_count(narration))
assert shot["duration"] >= rule - 1e-9, \
f"shot {i} declara menos de lo que su propia regla pide"
# Y contra la voz medida, no sólo contra la regla que la aproxima.
assert shot["duration"] >= spoken_seconds(narration) + NARRATION_PAD - 1e-9, \
f"shot {i} se quedaría corto para su propia voz" f"shot {i} se quedaría corto para su propia voz"
@@ -160,9 +166,11 @@ def test_the_prompt_gives_a_budget_the_model_can_count():
w, _ = writer("{}") w, _ = writer("{}")
prompt = w.build_prompt("Caso X", "material", None, "X.TEST") prompt = w.build_prompt("Caso X", "material", None, "X.TEST")
assert f"{NARRATION_WORDS_PER_SECOND:.1f} words a second" in prompt, \ # La constante exacta, no redondeada: el prompt trae una cuenta trabajada, y
# con "2.8" el divisor mostrado no reproduce el resultado mostrado.
assert f"{NARRATION_WORDS_PER_SECOND:g} words a second" in prompt, \
"sin el ritmo de la voz no hay cuenta que el modelo pueda hacer" "sin el ritmo de la voz no hay cuenta que el modelo pueda hacer"
assert f"words ÷ {NARRATION_WORDS_PER_SECOND:.1f}" in prompt assert f"words ÷ {NARRATION_WORDS_PER_SECOND:g}" in prompt
assert f"{NARRATION_WORD_BUDGET} words" in prompt assert f"{NARRATION_WORD_BUDGET} words" in prompt
@@ -187,6 +195,69 @@ def test_the_worked_example_obeys_the_budget_it_preaches():
assert round(sum(lines) / len(lines)) == NARRATION_WORDS_PER_LINE assert round(sum(lines) / len(lines)) == NARRATION_WORDS_PER_LINE
def test_the_longest_line_allowed_fits_in_the_longest_shot_allowed():
"""La contradicción que hacía infradeclarar, convertida en test.
El prompt pedía a la vez líneas de hasta 18 palabras, planos de 6 s como
mucho, y tiempo declarado suficiente para la propia voz. Las tres juntas son
imposibles 18 palabras piden 7,3 s y el modelo rompía la única que nadie
comprobaba. Si alguien vuelve a subir el tope de palabras a mano, esto salta.
"""
from src.generator.shortspec import (
MAX_SHOT_DURATION, NARRATION_WORDS_PER_LINE_MAX,
)
from src.generator.spec_contract import teachable_seconds
# En el caso malo: una línea al tope, partida en dos frases (dos pausas).
assert teachable_seconds(NARRATION_WORDS_PER_LINE_MAX, 2) <= MAX_SHOT_DURATION
# Y el tope es apretado, no una holgura cómoda que esconda otra vez el fallo:
# una palabra más ya no cabría.
assert teachable_seconds(NARRATION_WORDS_PER_LINE_MAX + 1, 2) > MAX_SHOT_DURATION
def test_the_prompt_rule_counts_the_pauses_and_not_only_the_words():
"""Palabras por segundo a secas es el mismo error que tenía el estimador.
Medido contra las 28 líneas que el bot ha narrado de verdad, `words/2.75 +
0.5` se quedaba corta en 14 y hasta 2,27 s: obedecerla al pie de la letra
seguía infradeclarando media docena de planos. La regla del prompt tiene que
llevar el término por frase, y tiene que ser LA MISMA que aplican los tests.
"""
w, _ = writer("{}")
prompt = w.build_prompt("Caso X", "material", None, "X.TEST")
assert "× sentences" in prompt
assert "count the sentences" in prompt
# Una línea troceada cuesta más que una seguida con las mismas palabras.
from src.generator.spec_contract import teachable_seconds
assert teachable_seconds(12, 3) > teachable_seconds(12, 1)
def test_the_worked_arithmetic_in_the_prompt_is_actually_right():
"""Un ejemplo numérico equivocado enseña la cuenta equivocada, y se lee antes
que la fórmula."""
import re as _re
from src.generator.spec_contract import sentence_count, teachable_seconds
w, _ = writer("{}")
prompt = w.build_prompt("Caso X", "material", None, "X.TEST")
example = json.loads(EXAMPLE.read_text())
worked = _re.findall(
r"[Ss]hot (\d+) speaks \w+ words in \w+ sentences?, so [^=]+= ([\d.]+)",
prompt)
# Sin esto el test pasa en vacío si alguien reescribe el párrafo.
assert len(worked) == 2, f"no se encontraron las cuentas trabajadas: {worked}"
for index, claimed in worked:
narration = example["shots"][int(index)]["narration"]
real = teachable_seconds(len(narration.split()), sentence_count(narration))
assert abs(real - float(claimed)) < 0.05, \
f"el prompt dice {claimed}s para shots.{index}, la regla da {real:.2f}s"
def test_prompt_says_out_loud_that_there_is_no_article_yet(): def test_prompt_says_out_loud_that_there_is_no_article_yet():
w, _ = writer("{}") w, _ = writer("{}")
assert "No article URL yet" in w.build_prompt("X", "m", None, "X.TEST") assert "No article URL yet" in w.build_prompt("X", "m", None, "X.TEST")