ND-récursion
Sommaire
- ~~ ND-récursion
- Forme combinateur
- Factorielle
- Forme déclaration
- ~> ND-Map
- Forme lift
- Forme applicative
- Broadcast ND-map
- ~[] Empty Topos
- Broadcast ND
- data.[~> f] Broadcast ND-map
- data.[~~ lambda] Broadcast ND-récursion
- Chaînage
- Mode d'exécution
- Pragmas ND
- Repli et diagnostic
- Mémoïsation
- Batching (Phase 6)
- Limites de récursion
Exemples d'utilisation de la ND-récursion (~~, ~>, ~[]).
La ND-récursion permet d'exprimer des computations récursives qui peuvent être exécutées en parallèle sans syntaxe
async/await. Le runtime choisit le mode d'exécution.
~~ ND-récursion
Forme combinateur
La forme combinateur applique la lambda avec une seed initiale:
# Countdown récursif
~~(5, (v, recur) => {
if v > 0 {
recur(v - 1)
} else {
v
}
})
# → 0
Explication: La lambda reçoit (v, recur) où:
v= valeur couranterecur= fonction pour continuer la récursion
Factorielle
~~(5, (n, recur) => {
if n <= 1 { 1 }
else { n * recur(n - 1) }
})
# → 120
Forme déclaration
Créer une fonction ND-récursive réutilisable:
countdown = ~~(n, recur) => {
if n > 0 { recur(n - 1) }
else { "done" }
}
countdown(10)
# → "done"
À ce stade, la lambda wrappée s'exécute comme une fonction normale. Une fois appelée avec une seed, elle déclenche la récursion.
~> ND-Map
Forme lift
Lifter une fonction dans le contexte ND:
f = ~> abs
f(-5)
# → 5
Forme applicative
Appliquer une fonction en contexte ND:
~>(list(-1, -2, -3), abs)
# → [1, 2, 3]
Broadcast ND-map
Mapper une fonction sur chaque élément:
list(-1, -2, 3).[~> abs]
# → [1, 2, 3]
Avec lambda:
list(1, 2, 3).[~> (x) => { x * 2 }]
# → [2, 4, 6]
~[] Empty Topos
Littéral représentant le topos vide (élément neutre):
empty = ~[]
Propriétés:
# Falsy en contexte booléen
if ~[] { 1 } else { 2 }
# → 2
# Égalité
~[] == ~[]
# → True
# Longueur
len(~[])
# → 0
Le topos vide sert d'élément identité pour les opérations ND. Il marque la terminaison dans les graphes de calcul.
Broadcast ND
Les opérateurs ND fonctionnent avec le broadcast sur collections.
data.[~> f] Broadcast ND-map
Applique une fonction à chaque élément en contexte ND:
# Map abs sur liste
list(-1, -2, 3).[~> abs]
# → [1, 2, 3]
# Map lambda inline
list(1, 2, 3).[~> (x) => { x * 2 }]
# → [2, 4, 6]
# Préserve le type tuple
tuple(-1, -2, 3).[~> abs]
# → (1, 2, 3)
data.[~~ lambda] Broadcast ND-récursion
Applique la ND-récursion à chaque élément:
# Factorielle sur liste
list(3, 5).[~~(n, recur) => {
if n <= 1 { 1 }
else { n * recur(n - 1) }
}]
# → [6, 120]
# Countdown sur chaque élément
list(3, 5, 2).[~~(v, recur) => {
if v > 0 { recur(v - 1) }
else { v }
}]
# → [0, 0, 0]
# Préserve le type tuple
tuple(3, 2, 4).[~~(n, recur) => {
if n <= 1 { 1 }
else { n * recur(n - 1) }
}]
# → (6, 2, 24)
Note: Le broadcast ND préserve automatiquement le type de la collection (list, tuple, set).
Chaînage
Les opérateurs ND se chaînent naturellement avec le broadcasting standard:
data = list(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
result = data.[if > 3].[* 2].[if < 15].[+ 1]
# > 3: [4,5,6,7,8,9,10]
# * 2: [8,10,12,14,16,18,20]
# < 15: [8,10,12,14]
# + 1: [9,11,13,15]
Mode d'exécution
Par défaut, exécution séquentielle. Le mode "thread" utilise rayon (Rust) pour distribuer les éléments sur un pool
de threads natifs, en relâchant le GIL pendant l'exécution.
Pragmas ND
Les pragmas permettent de contrôler le mode d'exécution :
# Mode parallèle (rayon)
pragma("nd_mode", ND.thread)
~~(huge_dataset, (data, recur) => { … })
Modes disponibles :
"sequential"(défaut) : exécution séquentielle, pas de overhead"thread": distribution rayon avecpy.detach()/Python::attach()par thread"process": vrais processus séparés (ProcessPoolExecutor), pour un parallélisme sans GIL
Le parallélisme repose sur rayon (Rust), pas sur des threads Python. Le GIL est relâché pendant le dispatch et réacquis par chaque worker pour les callbacks Python. La mémoïsation reste thread-safe via
Arc<Mutex>.
Repli et diagnostic
Un mode ne garantit pas qu'il s'exécutera. Quand un élément ne peut pas honorer le mode demandé — une capture qui ne
traverse pas la frontière d'un process, un worker qui échoue — le runtime retombe sur un mode plus simple (thread,
puis sequential) et rejoue l'élément. Le résultat reste correct ; seul le parallélisme demandé n'a pas eu lieu.
Deux familles de repli, traitées différemment :
- Préventif : le runtime sait d'avance que l'élément n'est pas distribuable (lambda ou seed non sérialisable, binaire worker absent). C'est un choix, pas une panne — silencieux.
- Réactif : le worker a démarré puis échoué. Le runtime émet alors un
CatnipNDFallbackWarning(une fois par batch) nommant le mode demandé, le mode effectif et la cause.
Le repli réactif se promeut en erreur fatale — utile, côté hôte Python, pour vérifier qu'un mode s'exécute bien tel que demandé :
import warnings
from catnip.exc import CatnipNDFallbackWarning
warnings.filterwarnings('error', category=CatnipNDFallbackWarning)
Un mode qui ne dit pas qu'il a échoué ment sur ce qu'il a fait. Le repli reste une bonne réponse ; le silence, non.
Mémoïsation
Le pragma nd_memoize active un cache automatique des résultats :
# Activer la mémoïsation
pragma("nd_memoize", True)
# Fibonacci avec mémoïsation - 11x speedup sur fib(25)
~~(25, (n, recur) => {
if n <= 1 { n }
else { recur(n - 1) + recur(n - 2) }
})
# → 75025
# Désactiver la mémoïsation (défaut)
pragma("nd_memoize", False)
Principe : Le scheduler cache les résultats par valeur seed. Si recur(n) est rappelé avec la même valeur n, le
résultat en cache est retourné au lieu de recalculer.
Cas d'usage optimal :
- Algorithmes avec redondance (Fibonacci, DP)
- Calculs coûteux sur mêmes valeurs
- Broadcast sur collections avec valeurs dupliquées
Performance :
- Fibonacci sans mémoïsation : O(2^n) appels
- Fibonacci avec mémoïsation : O(n) appels
- Speedup mesuré : 11x sur fib(25), plus élevé pour n plus grand
Batching (Phase 6)
Le pragma nd_batch_size contrôle la granularité du parallélisme en regroupant plusieurs items avant de les soumettre à
ThreadPoolExecutor :
# Configuration explicite
pragma("nd_mode", ND.process)
pragma("nd_workers", 8)
pragma("nd_batch_size", 10) # 10 items par batch
# Large collection - batching réduit l'overhead
range(1, 101).[~~(n, recur) => {
if n <= 1 { 1 }
else { n * recur(n - 1) }
}]
# Auto-calcul (défaut avec 0)
pragma("nd_batch_size", 0) # batch_size = ceil(len / (workers * 4))
Principe : Au lieu de soumettre chaque item individuellement à l'executor, on groupe batch_size items ensemble.
Cela réduit le nombre de submits et l'overhead de synchronisation.
Auto-calcul : Si batch_size = 0 (défaut), le scheduler calcule automatiquement pour obtenir ~4 batches par worker.
Détection intelligente : Pour les petites collections (< workers*2 items), le batching est automatiquement désactivé pour éviter l'overhead.
Cas d'usage optimal :
- Collections grandes (100+ items)
- Mode parallèle avec plusieurs workers
- Items avec temps de calcul variable
Combinaison avec mémoïsation :
pragma("nd_mode", ND.process)
pragma("nd_memoize", True)
pragma("nd_batch_size", 5)
# Broadcast sur collection avec doublons
# Batching: réduit overhead ThreadPoolExecutor
# Mémoïsation : évite les recalculs des valeurs déjà vues
list(10, 12, 10, 15, 12, 20).[~~(n, recur) => {
if n <= 1 { n }
else { recur(n - 1) + recur(n - 2) }
}]
Limites de récursion
La récursion ND est limitée à 200 appels imbriqués via recur(). Au-delà, une RecursionError est levée :
# Récursion infinie : déclenche RecursionError
~~(0, (v, recur) => { recur(v + 1) })
# RecursionError: maximum ND recursion depth exceeded
Cette limite est un garde-fou uniforme sur les runtimes, pas la mesure d'un mur atteint partout. Sous l'extension
Python, la réentrée de recur est interceptée dans la boucle de dispatch et empilée sur le tas : un niveau ne coûte
pratiquement pas de pile native, et 299 niveaux tiennent dans une pile de 96 Ko. Le runtime pur Rust, lui, réentre
réellement sur la pile native, et c'est là que la limite protège d'un débordement.
Pour les cas légitimes nécessitant plus de profondeur, préférer la récursion classique (fonctions =>) qui utilise le
frame stacking de la VM sans limite de stack Rust.
Principe : La sémantique du code reste identique, seul le mode d'exécution change. Le déterminisme est préservé - résultat identique en séquentiel et parallèle.
Les optimisations (mémoïsation, batching) réduisent le temps d'exécution sans changer le résultat.