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:
2026-09-15 15:13:07 +02:00
parent 6f12cab86f
commit 4a057862a8
7 changed files with 402 additions and 119 deletions
+139 -11
View File
@@ -72,6 +72,9 @@ GF_LAENGE_MODUS = {"1": "endpunkt", "2": "hoehe", "3": "steigend"}
VERTEILUNG = {"1": "haelfte", "2": "einlauf"}
IM_VF = {"1": "horizontal", "2": "vario-kurve", "3": "auf-ab"}
ENDPUNKT = {"1": "motorstation", "2": "weiter", "3": "kettenende"}
# Richtungswahl fuer "auf-ab", wenn die Kette bereits FLACH ankommt
# (vfl-frage-auf-ab-richtung: "1"=Auf, "2"=Ab) - siehe Kern.vf_einheit.
RICHTUNG = {"1": "auf", "2": "ab"}
ENDE3 = {"1": "ja-mit-es", "2": "ja-ohne-es", "3": "nein"}
ENDE2 = {"1": "ja-mit-es", "2": "nein"}
# Ja/Nein-Antworten des Dialogs. Als Tabelle gefuehrt (und nicht als
@@ -147,13 +150,33 @@ class Grammatik(object):
fragt. Der heutige Ablauf fragt sie dort NICHT mehr: die Aufrufstelle
in vf-linienzug-modus gibt "2" (alles am Einlauf) fest vor
(vfl-vf-einheit-abschluss, Argument gf-verteilung-fest).
linie_gf_laenge_modus
Ob eine "Linie-GF"-Sektion die Vorwahl "laenge_modus" (endpunkt/
hoehe/steigend) kennt und "Ist das Kettenende?" SOFORT danach fragt
(NEU 2026-09-12, siehe vsp-sektion in Lisp/vf_spec.lsp). Die
eingefrorene Aufzeichnung stammt von VOR dieser Erweiterung: dort
folgen auf Laenge/Gefaelle direkt Winkel/Hoehe, und "Ist das
Kettenende?" steht ganz am Schluss der Sektion.
linie_hz_je_segment
Ob vfl-in-abstand die gesnappte Fahrtrichtung (hz) bei JEDEM Segment
journalisiert (linienzug_tests.json/-stufe2.json, 2026-09-12: auch
nach einer Vario-Kurve oder einem neuen Glied) oder nur beim
ALLERERSTEN Segment der ganzen Kette (eingefrorene Aufzeichnung,
hm_recformat.json). Muss grammatik-gebunden sein, nicht per
Lookahead entschieden: ein direkt auf DL folgendes REAL kann auch
"eingang_hoehe" einer Horizontal-VF-Sektion sein (siehe
Kern._opt_eingang_hoehe) - blinder Lookahead wuerde das faelschlich
als hz konsumieren und die Queue verschieben.
"""
def __init__(self, name, menu_start, horizontal_vf_fragt_gf_verteilung):
def __init__(self, name, menu_start, horizontal_vf_fragt_gf_verteilung,
linie_gf_laenge_modus=True, linie_hz_je_segment=True):
self.name = name
self.menu_start = menu_start
self.horizontal_vf_fragt_gf_verteilung = (
horizontal_vf_fragt_gf_verteilung)
self.linie_gf_laenge_modus = linie_gf_laenge_modus
self.linie_hz_je_segment = linie_hz_je_segment
def __repr__(self):
return "Grammatik(%r)" % self.name
@@ -161,9 +184,13 @@ class Grammatik(object):
# Stand der EINGEFRORENEN Aufzeichnung (hm_recformat.json) - Vorgabe, damit
# alle bestehenden Aufrufer unveraendert weiterlaufen.
GRAMMATIK_AUFZEICHNUNG = Grammatik("aufzeichnung", MENU_START, True)
GRAMMATIK_AUFZEICHNUNG = Grammatik("aufzeichnung", MENU_START, True,
linie_gf_laenge_modus=False,
linie_hz_je_segment=False)
# Stand des LEBENDEN Bau-Ablaufs (vf_linienzug.lsp) - fuer neue Uebersetzungen.
GRAMMATIK_AKTUELL = Grammatik("aktuell", MENU_START_AKTUELL, False)
GRAMMATIK_AKTUELL = Grammatik("aktuell", MENU_START_AKTUELL, False,
linie_gf_laenge_modus=True,
linie_hz_je_segment=True)
# ============================================================
@@ -400,12 +427,35 @@ class Kern(object):
return wert
def _linie(self, frage):
"""Segmentlaenge, und NUR beim allerersten Segment der Kette die
gesnappte Fahrtrichtung (vfl-in-abstand journalisiert sie nur bei
freier Richtungswahl; jedes spaetere Segment erbt die Richtung des
Vorgaengers)."""
"""Segmentlaenge, und optional die gesnappte Fahrtrichtung
(vfl-in-abstand journalisiert sie bei freier Richtungswahl; ein
Segment, dessen Richtung schon feststeht, erbt sie vom Vorgaenger und
hat keinen eigenen Journal-Eintrag). Grammatik-gebunden
(linie_hz_je_segment), NICHT per blindem Lookahead: ein direkt auf DL
folgendes REAL kann auch "eingang_hoehe" einer Horizontal-VF-Sektion
sein (siehe _opt_eingang_hoehe, dort IMMER ein zweiter, eigener
Lookahead direkt danach) - ein Lookahead hier ohne Grammatik-Wissen
wuerde das faelschlich als hz konsumieren und die Queue verschieben.
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.
Ausnahme "kettenende_ziel" (vf_einheit, Option 3 "Kettenende"):
Lisp/vf_linienzug.lsp:7102 uebergibt vfl-body-abschluss dort
(car (frame->hz-winkel frame)) - die Richtung wird aus dem
bestehenden Frame ABGELEITET, nie erfragt. vfl-neue-linie-messen
(Zeile 4829) misst danach nur noch die Distanz. Ein REAL direkt nach
dem DL ist an dieser Stelle darum IMMER die Zielhoehe, nie ein hz -
wurde bisher fälschlich als hz verschluckt (Token-Desync am
Kettenende, siehe hundm05.json LZ_01/VF_0-Rekonstruktion)."""
dl = float(self.nimm("DL"))
hz = self.real() if (self.erster_dl and self.art() == "REAL") else None
if frage == "kettenende_ziel":
erwartet = False
else:
erwartet = self.erster_dl or self.grammatik.linie_hz_je_segment
hz = self.real() if (erwartet and self.art() == "REAL") else None
self.erster_dl = False
self.sink.linie(dl, hz, frage)
@@ -415,6 +465,20 @@ class Kern(object):
if self.art() == "INT":
self.sink.int_feld("winkel_idx", self.ganz(), "winkel_idx")
def _opt_gf2_bestaetigung(self):
"""Stufe-2-Fallback (vfl-frage-gf2-minimum, Lisp/vf_linienzug.lsp):
steht nur im Journal, wenn die am Kettenende automatisch berechnete
GF2-Laenge UNTER der ueblichen Mindestlaenge liegt (Stufe 1 -
Winkeltabelle - hat dann keinen gueltigen Kandidaten geliefert).
"ja" = mit der exakt berechneten (kuerzeren) Laenge bauen (trifft den
Zielpunkt exakt), "nein" = auf die Mindestlaenge auffuellen
(Zielpunkt verschiebt sich entsprechend). Faellt die Frage nicht an
(GF2 lang genug oder Stufe 1 erfolgreich), gibt es keinen
Journal-Eintrag - genau wie bei _opt_winkel_idx."""
if self.art() == "STR":
self._ja("gf2_exakt_bauen", "GF2 unter Mindestlaenge - exakt bauen",
"gf2_minimum")
def _opt_eingang_hoehe(self):
"""Ziel-Hoehe fuer den GF1-Einlauf einer mit "Horizontal-VF"
eroeffneten VF-Einheit (vfl-prompt-hoehe-eingang-gf1). Sie wird nur
@@ -494,7 +558,10 @@ class Kern(object):
if label == "GF-Bogen":
self.sink.int_feld("winkel", self.ganz(), "gf_bogen_winkel")
self._code("seite", SEITE, "GF-Bogen-Seite", "gf_bogen_seite")
elif label == "Linie-GF":
elif label == "Linie-GF" and not self.grammatik.linie_gf_laenge_modus:
# Eingefrorene Aufzeichnung (GRAMMATIK_AUFZEICHNUNG): stammt von
# VOR der "laenge_modus"-Erweiterung - Laenge/Gefaelle direkt,
# "Ist das Kettenende?" ganz am Schluss. Siehe Grammatik-Doku.
self._linie("gf_linie")
if self._code("gefaelle", GEFAELLE, "Gefaelle",
"gefaelle") == "winkel":
@@ -502,6 +569,56 @@ class Kern(object):
else:
self.sink.real_feld("hoehe", self.real(), "gf_hoehe")
self._code("ende", ENDE3, "Kettenende", "ende3")
elif label == "Linie-GF":
# Reihenfolge seit 2026-09-12 (siehe Lisp/vf_spec.lsp
# vsp-sektion, dortiger Kommentar): erst die Vorwahl
# "laenge_modus" (endpunkt/hoehe/steigend), DANN sofort "Ist
# das Kettenende?" - beide muessen VOR den modus-spezifischen
# Feldern gelesen werden, der alte Ablauf (Laenge/Gefaelle
# zuerst, Kettenende-Frage zuletzt) ist veraltet.
modus = self._code("laenge_modus", GF_LAENGE_MODUS,
"GF-Laengenmodus", "gf_laenge_modus")
ende = self._code("ende", ENDE3, "Kettenende", "ende3")
if ende == "ja-mit-es":
# Das ES-Element wird HIER inline erfragt (Winkel/Seite,
# OHNE eigenen STEP-Marker) - noch nicht decodierbar, bisher
# in keiner Aufzeichnung vorgekommen. Fail fast statt falsch
# zu raten (siehe Modul-Kopf, Invariante I3).
raise ValueError(
"Linie-GF mit ende=\"ja-mit-es\" wird von diesem Parser "
"noch nicht unterstuetzt (Token %d)" % (self.pos - 1))
if modus == "steigend":
smodus = self._code("steigend_modus", GF_LAENGE_MODUS,
"Steigend-Modus", "gf_laenge_modus")
if smodus == "endpunkt":
self._linie("gf_linie")
elif self.erster_dl:
raise ValueError(
"Linie-GF steigend/%s als allererstes Segment der "
"Kette wird von diesem Parser noch nicht "
"unterstuetzt (Token %d)" % (smodus, self.pos - 1))
if smodus == "hoehe":
self.sink.real_feld("hoehe", self.real(), "gf_hoehe")
self.sink.real_feld("winkel", self.real(), "gf_winkel")
elif modus == "hoehe":
if self.erster_dl:
raise ValueError(
"Linie-GF hoehe als allererstes Segment der Kette "
"wird von diesem Parser noch nicht unterstuetzt "
"(Token %d)" % (self.pos - 1))
self._ja("sep_vor", "Separator vor", "sep_vor")
self._ja("sep_nach", "Separator nach", "sep_nach")
self.sink.real_feld("hoehe", self.real(), "gf_hoehe")
self.sink.real_feld("winkel", self.real(), "gf_winkel")
else: # "endpunkt"
self._linie("gf_linie")
self._ja("sep_vor", "Separator vor", "sep_vor")
self._ja("sep_nach", "Separator nach", "sep_nach")
if self._code("gefaelle", GEFAELLE, "Gefaelle",
"gefaelle") == "winkel":
self.sink.real_feld("winkel", self.real(), "gf_winkel")
else:
self.sink.real_feld("hoehe", self.real(), "gf_hoehe")
elif label == "Linie-VF":
self._linie("vf_linie")
self.sink.real_feld("hoehe", self.real(), "vf_zielhoehe")
@@ -570,6 +687,7 @@ class Kern(object):
self._linie("kettenende_ziel")
self.sink.real_feld("hoehe", self.real(), "kettenende_hoehe")
self._opt_winkel_idx()
self._opt_gf2_bestaetigung()
self.sink.sub_ende()
ziel_ende = True
break
@@ -592,8 +710,18 @@ class Kern(object):
"vario_variante")
else: # auf-ab
self._linie("aufab_sub")
self.sink.real_feld("hoehe", self.real(), "aufab_hoehe")
self._opt_winkel_idx()
# Zwei Auspraegungen, am naechsten Token erkennbar (Kern kennt
# keinen eigenen "Modus"-Schalter, vf-linienzug-modus
# entscheidet selbst anhand des Zeichnungszustands): kommt die
# Kette bereits FLACH an, folgt eine STR-Richtungswahl (Auf/
# Ab) + Winkeltabelle statt einer direkten Zielhoehe (REAL).
if self.art() == "STR":
self._code("richtung", RICHTUNG, "Auf/Ab-Richtung",
"aufab_richtung")
self._opt_winkel_idx()
else:
self.sink.real_feld("hoehe", self.real(), "aufab_hoehe")
self._opt_winkel_idx()
self.sink.sub_ende()
# --- Abschluss (vfl-vf-einheit-abschluss) ---
# Ohne ES-Wunsch am Zielpunkt endet die Kette dort direkt; bei