Chapitre 02.03 — Railway Oriented Programming#

Le cœur d’Ocarina. Tout le DSL repose sur cette mécanique : représenter le succès et l’échec comme deux rails parallèles, faire qu’un échec bascule le train sur le rail d’échec, et l’y fait rester (court-circuit), préserver la composition syntaxique pour que le scénario reste lisible.

Plan#

#FichierSujet
0101-result.mdResult[T] = Ok[T] | Fail, is_ok/is_fail
0202-action-chain-states.mdMachine à états ActionStart → ActionFailure → ActionSuccess → ActionChain.
0303-neutral-rail.mdRail d’échec : NeutralActionStart / NeutralActionFailure / NeutralActionSuccess.
0404-chain-actions-fold.mdChainRunner[T] (thunk) + chain_actions (fold).
0505-create-act-hooks.mdcreate_act et ses hooks (on_failure, on_run_effect, act_counter_effect).
0606-drive-page.mddrive_page, alias sémantique.
0707-match-page-when.mdmatch_pagewhen + politique d’exceptions.

Filiation#

L’inspiration directe est le Railway Oriented Programming popularisé par Scott Wlaschin (F#).

Tordre l’implémentation de ROP (Railway Oriented Programming) d’Ocarina jusqu’à lui en faire perdre tout son sens, puisqu’ils ne savaient même pas ce que signifie ROP.

L’implémentation Python d’Ocarina est faite respectueusement : elle ajoute la machine à états du builder (Typestate Pattern), le rail d’échec neutralisé, et la composition paresseuse via ChainRunner. Voir 02-action-chain-states.md pour la mécanique complète.

Métaphore reprise du module#

Extrait du docstring de action_chain.py :

Railway metaphor:

Code as railway track with two rails:

  • Success rail (top): Actions execute normally → Ok results
  • Failure rail (bottom): Action failed → Fail results

When an action fails, the train switches to the failure rail and stays there (short-circuits). Subsequent actions become no-ops.

                       act1           act2           act3
                        │              │              │
                        ▼              ▼              ▼
Success rail  ─────[ EXECUTE ]────[ EXECUTE ]────[ EXECUTE ]─────┐
                        │                                        │
                        │ (fail → switch)                        ├──►  Result
                        ▼                                        │
Failure rail  ─────[  NO-OP  ]────[  NO-OP  ]────[  NO-OP  ]─────┘

Deux rails parallèles, mêmes étapes. Si act1 échoue, le train bascule sur
le rail d'échec et y reste : les actes suivants restent appelables (l'API
.failure/.success/.execute existe), mais ils ne font rien (no-op).

Pourquoi ROP plutôt que try/except#

Aspecttry/except classiqueROP
Propagation de l’échecImplicite (l’exception remonte)Explicite (la valeur Fail découle)
Vérification par le type-checkerImpossible (toutes les exceptions sont possibles partout)Forte (Result[T] est une union discriminée)
CompositionImbrication (try { … try { … } })Linéaire (.then().then().then())
Court-circuitManuel (if exc: return)Automatique (rail d’échec)
HandlersCouplés à un except procheDécouplés (.failure(handler), .success(handler))

Conséquence pratique : le nombre de try/except dans le code projet chute drastiquement. L’utilisateur ne ressent plus la pression de prévoir « tous les try/except possibles et imaginables », il reprend le contrôle sur pourquoi il voudrait en utiliser un, dans les rares cas où ça reste pertinent.

Le contrat global du DSL#

PrimitiveType de retourLisibilité
act(pom, action)ActionStart[TPOM]« je commence un pas »
.failure(h)ActionFailure[TPOM]« sur l’échec, fais h »
.success(h')ActionSuccess[TPOM]« sur le succès, fais h’ »
.execute()ActionChain[TPOM]« exécute le pas »
drive_page(act1, act2, …)ChainRunner[TPOM]« je prends le contrôle d’une page »
match_page(branches=[when(...)])ChainRunner[Any]« je branche selon l’état observé »

Tout scenario.test_chain est une Sequence[ChainRunner[Any]]. Tout point d’extension utilisateur (un nouveau combinateur, un skip_if, etc.) doit retourner un ChainRunner. Voir ../06-scenario.md et ../../03-functional/