fix: VF-Linienzug Journal-Reset, Spec-Grammatik (auf-ab/Linie-GF) und hz-Bug in HundM05

- vfl-journal-reset-lauf ans Funktionsanfang verschoben (Modus 1/2/3,
  Lisp/vf_linienzug.lsp): stand bisher nach der Startpunkt/Hoehe/AS-
  Praeambel und loeschte diese beim Reset selbst wieder - Journal begann
  mit dem ersten STEP-Marker statt PT, betraf jede Kette.
- Spec-Grammatik erweitert (Lisp/vf_spec.lsp, lib/vf_linienzug_grammatik.py,
  lib/vf_spec_export.py): "auf-ab" bei bereits flach ankommender Kette
  (Richtungswahl statt Zielhoehe) und neue Linie-GF-Fragereihenfolge
  (Kettenende direkt nach laenge_modus, inkl. vorgezogenem ES). Tests in
  tests/test_vf_spec.lsp/.py angepasst.
- lib/vf_linienzug_grammatik.py: _linie("kettenende_ziel") erwartete
  faelschlich ein hz-REAL nach der Distanz - der reale Bauablauf
  (vfl-body-abschluss) leitet die Richtung dort aus dem Frame ab und
  fragt sie nie. Fuehrte zu einem Token-Desync beim Dekodieren echter
  Kettenende-Journale.
- tests/testdata/hundm05.json (VF_hundm05_LZ_01): 5 ueberfluessige
  hz-Felder entfernt. vsp-linie injiziert bei JEDEM vorhandenen hz-Feld
  einen Richtungs-Token, unabhaengig vom tatsaechlichen Bauablauf - die
  Felder desyncten den Replay und liessen die Kette stumm mit der
  falschen (verkuerzten) Geometrie fertig bauen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Samer Ayadi
2026-09-15 15:13:07 +02:00
co-authored by Claude Sonnet 5
parent 6f12cab86f
commit 4a057862a8
7 changed files with 402 additions and 119 deletions
+87 -31
View File
@@ -51,8 +51,9 @@ from vf_journal_export import arrays_einzeilig # noqa: E402
from vf_linienzug_grammatik import ( # noqa: E402,F401
ENDE2, ENDE3, ENDPUNKT, GEFAELLE, GF_LAENGE_MODUS, GLIEDER,
GRAMMATIK_AKTUELL, GRAMMATIK_AUFZEICHNUNG, Grammatik, IM_VF, MENU_START,
MENU_START_AKTUELL, MENU_WEITER, SEITE, VARIANTE, VERTEILUNG, VF_GLIEDER,
Sink, code_aus_ja, code_aus_wert, lies_journal, pruefe_flachen_freitext)
MENU_START_AKTUELL, MENU_WEITER, RICHTUNG, SEITE, VARIANTE, VERTEILUNG,
VF_GLIEDER, Sink, code_aus_ja, code_aus_wert, lies_journal,
pruefe_flachen_freitext)
PREFIX = "[vf_spec_export]"
@@ -302,6 +303,13 @@ class SpecSchreiber(object):
self.tok = []
self.erster_dl = True
self.wo = "Spec"
# Siehe sektion()/schreib(): bei "Linie-GF" mit ende="ja-mit-es"
# zieht vf-linienzug-modus die ES-Winkel/Seite-Frage direkt in
# dieselbe Antwortrunde (Fussabdruck muss vor der Laengenberechnung
# feststehen) - die naechste Sektion (glied="ES") ist dann bereits
# vollstaendig verbraucht und muss beim Weiterzaehlen uebersprungen
# werden. Analog zu *vsp-es-verbraucht* in Lisp/vf_spec.lsp.
self.es_verbraucht = False
# --- Ausgabe ---
@@ -328,28 +336,50 @@ class SpecSchreiber(object):
def linie(self, quelle):
self.zahl("DL", self.feld(quelle, "dl"))
if self.erster_dl:
# Beim ersten Segment MUSS die Richtung dabeistehen (die Kette hat
# noch keine geerbte), bei jedem spaeteren darf sie es NICHT -
# sonst verschiebt sich die ganze Queue um einen Eintrag.
# Feldpraesenz entscheidet, nicht "erster_dl": die eingefrorene
# Aufzeichnung (hm_recformat.json) journalisiert die Richtung nur
# beim allerersten Segment der ganzen Kette, der heutige Ablauf
# (linienzug_tests.json/-stufe2.json, 2026-09-12) dagegen bei JEDEM
# Segment neu, auch nach einer Vario-Kurve oder einem neuen Glied -
# siehe Kern._linie in vf_linienzug_grammatik.py (Gegenstueck beim
# Lesen, dort per Lookahead statt Feldpraesenz, weil die Spec beim
# Lesen noch nicht existiert). Beim ALLERERSTEN Segment MUSS die
# Richtung dabeistehen (die Kette hat noch keine geerbte).
if self.erster_dl and "hz" not in quelle:
raise ValueError("%s: Feld \"hz\" fehlt (Pflicht beim ersten "
"Segment der Kette)" % self.wo)
if "hz" in quelle:
self.zahl("REAL", self.feld(quelle, "hz"))
self.erster_dl = False
elif "hz" in quelle:
raise ValueError("%s: hz steht nur beim ERSTEN Segment der Kette "
"im Journal (vfl-in-abstand)" % self.wo)
self.erster_dl = False
def winkel_idx(self, quelle):
if "winkel_idx" in quelle:
self.raus("INT", str(int(quelle["winkel_idx"])))
def gf2_bestaetigung(self, quelle):
# Stufe-2-Fallback (vfl-frage-gf2-minimum) - siehe
# Kern._opt_gf2_bestaetigung in vf_linienzug_grammatik.py fuer die
# volle Erklaerung. Nur vorhanden, wenn die berechnete GF2-Laenge
# am Kettenende unter der Mindestlaenge lag.
if "gf2_exakt_bauen" in quelle:
self.raus("STR", code_aus_ja(quelle["gf2_exakt_bauen"]))
# --- Ablauf ---
def schreib(self):
self.praeambel()
sektionen = self.spec.get("sektionen") or []
for nr, sek in enumerate(sektionen, 1):
self.wo = "Sektion %d (%s)" % (nr, sek.get("glied"))
self.sektion(sek, erster=(nr == 1))
if not sektionen:
raise ValueError("Spec hat keine Sektionen")
n = len(sektionen)
i = 0
while i < n:
sek = sektionen[i]
naechste = sektionen[i + 1] if i + 1 < n else None
self.wo = "Sektion %d (%s)" % (i + 1, sek.get("glied"))
self.es_verbraucht = False
self.sektion(sek, erster=(i == 0), naechste=naechste)
i += 2 if self.es_verbraucht else 1
return self.tok
def praeambel(self):
@@ -369,7 +399,7 @@ class SpecSchreiber(object):
self.raus("STR", code_aus_wert(SEITE, self.feld(start, "as_seite"),
"as_seite"))
def sektion(self, sek, erster):
def sektion(self, sek, erster, naechste=None):
label = self.feld(sek, "glied")
if label == "ES":
self.es_glied(sek)
@@ -393,22 +423,37 @@ class SpecSchreiber(object):
# DL(+hz)-Ausgabe (self.linie) faellt bei "hoehe"/"steigend"+
# "hoehe" NUR beim allerersten Segment der Kette an - siehe
# self.linie/self.erster_dl.
#
# ACHTUNG Rundlauf-Test (TestRundlauf.test_verlustfrei): die 5
# echten HundM-Ketten (hm_recformat.json) wurden VOR dieser
# Erweiterung aufgezeichnet und enthalten daher fuer ihre
# Linie-GF-Sektionen KEINE Separator-Antworten. spec_aus_journal
# (Leser, unveraendert) liefert fuer sie weiterhin die alte,
# separator-lose Spec-Form; journal_aus_spec (dieser Schreiber)
# haengt bei modus="endpunkt" aber IMMER die beiden neuen
# sep_vor/sep_nach-Tokens an. Der Rundlauf ist fuer Ketten mit
# Linie-GF damit bewusst NICHT mehr verlustfrei - das ist eine
# inhaerente Folge der neuen Pflichtfragen im interaktiven Ablauf
# (vf-linienzug-modus), kein Fehler in diesem Schreiber. Siehe
# doc/vf_linienzug_modus1_anleitung.md / Commit-Historie.
modus = self.feld(sek, "laenge_modus", pflicht=False) or "endpunkt"
self.raus("STR", code_aus_wert(GF_LAENGE_MODUS, modus,
"laenge_modus"))
# "Ist das Kettenende?" wird SOFORT nach der Laenge-Modus-Wahl
# gefragt (vor Separator/Gefaelle/Laenge) - Gegenstueck zu
# vsp-sektion in Lisp/vf_spec.lsp (Kommentar dort: "NEU
# 2026-09-12"). Bei "ja-mit-es" fragt vf-linienzug-modus DIREKT im
# Anschluss auch ES-Winkel/-Seite (deren Fussabdruck muss schon in
# die Laengenberechnung eingehen) - die Spec beschreibt das
# ES-Element trotzdem als EIGENE, nachfolgende Sektion
# (glied="ES") fuer bessere Lesbarkeit. Die beiden Tokengruppen
# werden hier zusammengezogen: "ende" + (bei ja-mit-es) die
# Winkel/Seite-Tokens der naechsten Sektion, vorgezogen - die
# eigentliche ES-Sektion ist dadurch schon vollstaendig
# verbraucht (self.es_verbraucht, von schreib() zum Ueberspringen
# ausgewertet), sonst kaemen ihre Tokens ein zweites Mal.
ende_wert = self.feld(sek, "ende")
self.raus("STR", code_aus_wert(ENDE3, ende_wert, "ende"))
if ende_wert == "ja-mit-es":
if naechste is None:
raise ValueError("%s: ende=\"ja-mit-es\", aber keine "
"folgende ES-Sektion vorhanden" % self.wo)
if naechste.get("glied") != "ES":
raise ValueError("%s: ende=\"ja-mit-es\" erwartet eine "
"direkt folgende Sektion mit "
"glied=\"ES\"" % self.wo)
self.raus("STR", str(self.feld(naechste, "winkel")))
self.raus("STR", code_aus_wert(SEITE,
self.feld(naechste, "seite"),
"seite (ES, vorgezogen)"))
self.es_verbraucht = True
if modus == "steigend":
smodus = (self.feld(sek, "steigend_modus", pflicht=False)
or "endpunkt")
@@ -440,8 +485,6 @@ class SpecSchreiber(object):
self.raus("STR", code_aus_wert(GEFAELLE, art, "gefaelle"))
self.zahl("REAL", self.feld(sek, "winkel" if art == "winkel"
else "hoehe"))
self.raus("STR", code_aus_wert(ENDE3, self.feld(sek, "ende"),
"ende"))
elif label == "Linie-VF":
self.linie(sek)
self.zahl("REAL", self.feld(sek, "hoehe"))
@@ -529,6 +572,7 @@ class SpecSchreiber(object):
self.linie(sub)
self.zahl("REAL", self.feld(sub, "hoehe"))
self.winkel_idx(sub)
self.gf2_bestaetigung(sub)
else:
if letzter:
raise ValueError("%s: VF-Einheit endet mit sub %r - der "
@@ -551,8 +595,20 @@ class SpecSchreiber(object):
"variante"))
else: # auf-ab
self.linie(sub)
self.zahl("REAL", self.feld(sub, "hoehe"))
self.winkel_idx(sub)
# Zwei Auspraegungen, per Feldpraesenz unterschieden (kein
# separates Modus-Flag - "richtung" IST die Aussage, siehe
# vsp-vf-einheit in Lisp/vf_spec.lsp): kommt die Kette
# bereits FLACH an, fragt vf-linienzug-modus die Richtung
# (Auf/Ab) + waehlt den Winkel aus einer Kandidatentabelle
# statt einer direkten Zielhoehe.
if "richtung" in sub:
self.raus("STR", code_aus_wert(RICHTUNG,
self.feld(sub, "richtung"),
"richtung"))
self.winkel_idx(sub)
else:
self.zahl("REAL", self.feld(sub, "hoehe"))
self.winkel_idx(sub)
ende = self.feld(vf, "ende")
if ende in ("zielpunkt-ohne-es", "automatisch"):
return # keine Frage