BROADCAST_SPEC

Voir aussi : BROADCAST_RATIONALE.md pour la motivation et les comparaisons.

Le Concept

Notation et Opérations

Le broadcasting applique des opérations sur des collections (listes, tuples, etc.) de manière uniforme, sans branches conditionnelles.

Cinq modes d'opération :

Syntaxe Mode Résultat Exemple
data.[op value] Map Collection de même taille avec transformation list(1,2,3).[* 2][2,4,6]
data.[if op value] Filter Collection filtrée (taille ≤ originale) list(1,2,3).[if > 1][2,3]
data.[mask] Masque booléen Collection filtrée par masque list(1,2,3).[list(True,False,True)][1,3]
data.[~> f] ND-map Applique f à chaque feuille scalaire list(list(-1,2),list(-3,4)).[~> abs][[1,2],[3,4]]
data.[~~ lambda] ND-recursion Applique ~~ à chaque feuille scalaire list(3,5).[~~ factorial][6,120]

Principe

Le broadcasting permet d'écrire des opérations qui portent indifféremment sur des scalaires ou des collections, sans embranchements logiques (if/elif/else).

Une seule expression fonctionne que les données soient :

  • Un scalaire (int, float, str, bool)
  • Une liste Python
  • Un tuple
  • Tout autre itérable

Syntaxe Implémentée : A.[op M]

La syntaxe Opération à Droite a été choisie.

Syntaxe

target.[operator operand]  # Opération binaire (map)
target.[operator]           # Opération unaire (map)
target.[lambda]             # Lambda/fonction (map)
target.[if operator operand]  # Filtrage conditionnel
target.[if lambda]          # Filtrage par lambda
target.[boolean_mask]       # Indexation par masque booléen

Exemples Fonctionnels

# Multiplication
doubled = data.[* 2]

# Addition
shifted = values.[+ 10]

# Puissance
squares = data.[** 2]

# Comparaison (retourne booléens)
masks = data.[> 3]

# Filtrage conditionnel (retourne éléments)
filtered = data.[if > 3]

# Lambda
tripled = data.[(x) => { x * 3 }]

Avantages de Cette Syntaxe

  • Chaînage explicite : data.[* 2].[+ 10]
  • Style orienté données : Lecture gauche à droite
  • Familier : Rappelle .apply() de pandas
  • Lisible : L'objet traité vient en premier

digression : en mode maths, quand les deux côtés sont "dimensionnés", on voit des écritures du genre [A].[B] (intuition bifoncteur). Ici on garde un seul côté objet explicite : le target. L'autre côté est embarqué dans [operator operand]. C'est volontaire : lecture gauche → droite, sans dupliquer la logique de parcours.


Broadcast sur scalaires littéraux

La notation de broadcast .[…] s'applique aussi aux scalaires littéraux. Un littéral (ex: 5, 3.14, "foo") est un primary et peut donc chaîner des membres, y compris un broadcast.

5.[+ 10]        # → 15
(2 + 3).[* 2]   # → 10
5.[abs]         # → 5

# Filtrage sur un scalaire : retourne une liste
5.[if > 0]      # → list(5)
5.[if < 0]      # → list()  (liste vide)

Map, Filter et Masques Booléens

Mermaid diagram lang__BROADCAST_SPEC--m001 Mermaid diagram lang__BROADCAST_SPEC--m001

Distinction Map vs Filter

Le broadcasting supporte deux modes distincts :

Map (transformation) : Transforme chaque élément et retourne une collection de même taille

data = list(3, 8, 2, 9, 5)
masks = data.[> 5]      # [False, True, False, True, False]

Filter (filtrage) : Ne garde que les éléments qui satisfont une condition

data = list(3, 8, 2, 9, 5)
filtered = data.[if > 5]  # [8, 9]

La différence est critique pour le chaînage d'opérations :

# PIÈGE : map puis multiply donne 0 et 2
data.[> 5].[* 2]       # [0, 2, 0, 2, 0]  (False*2=0, True*2=2)

# CORRECT : filter puis multiply
data.[if > 5].[* 2]    # [16, 18]  (garde 8 et 9, puis multiplie)

Indexation par Masque Booléen

Un masque booléen (liste/tuple de bool) peut être utilisé pour filtrer :

data = list(10, 20, 30, 40)
mask = list(True, False, True, False)
result = data.[mask]    # [10, 30]

Workflow suggéré : générer un masque puis le réutiliser

data = list(3, 8, 2, 9, 5)
mask = data.[> 5]       # [False, True, False, True, False]
result = data.[mask]    # [8, 9] - équivaut à data.[if > 5]

Erreurs possibles

Masque de longueur incompatible :

data = list(10, 20, 30)
mask = list(True, False)
data.[mask]  # Error: Mask size mismatch: target has 3 elements, mask has 2

Masque non booléen :

data = list(10, 20, 30)
mask = list(1, 0, 1)
data.[mask]  # Error: Mask must be a list or tuple of booleans, got list with non-boolean elements

Réutilisation du même masque sur plusieurs collections

data1 = list(10, 20, 30, 40)
data2 = list("a", "b", "c", "d")
mask = data1.[> 20]     # [False, False, True, True]
result1 = data1.[mask]  # [30, 40]
result2 = data2.[mask]  # ["c", "d"]

Filtrage avec Lambdas

Le filtrage conditionnel supporte les lambdas arbitraires :

# Nombres pairs
data = list(1, 2, 3, 4, 5, 6)
pairs = data.[if (x) => { x % 2 == 0 }]  # [2, 4, 6]

# Conditions complexes
data = list(-5, 3, -2, 8, 0, -1)
result = data.[if (x) => { x > 0 and x < 5 }]  # [3]

Préservation du Type

Le filtrage préserve le type de la collection d'origine :

# Liste → Liste
data_list = list(1, 2, 3, 4)
result = data_list.[if > 2]  # [3, 4] (type: list)

# Tuple → Tuple
data_tuple = tuple(1, 2, 3, 4)
result = data_tuple.[if > 2]  # (3, 4) (type: tuple)

Note sur l'invariance des collections : Les opérations de broadcasting préservent toujours le type de la collection d'origine. Une liste reste une liste, un tuple reste un tuple - bref, rien ne se transforme subitement en autre chose pendant le trajet. Aucune démarche n'est nécessaire : un changement de type n'est autorisé que lorsqu'il est explicitement demandé. Cette règle s'applique récursivement, sauf si elle s'applique déjà, auquel cas elle s'applique quand même.

Opérations Supportées

Opérateurs Arithmétiques

Broadcasting Scalaire

# Multiplication
2.[x]          # x * 2
x.[* 2]        # x * 2

# Addition
10.[x]         # x + 10
x.[+ 10]       # x + 10

# Soustraction
100.[- x]      # 100 - x
x.[- 50]       # x - 50

# Division
1.[/ x]        # 1 / x
x.[/ 2]        # x / 2

# Puissance
2.[** x]       # 2 ** x
x.[** 2]       # x ** 2

# Modulo
x.[% 10]       # x % 10

Broadcasting Liste-à-Liste

Opérations élément-par-élément entre deux collections :

a = list(1, 2, 3)
b = list(10, 20, 30)

# Addition élément par élément
a.[+ b]        # [11, 22, 33]

# Multiplication élément par élément
a.[* b]        # [10, 40, 90]

# Division élément par élément
b.[/ a]        # [10.0, 10.0, 10.0]

# Puissance élément par élément
a.[** b]       # [1**10, 2**20, 3**30]

Les deux collections doivent avoir la même taille, sinon le résultat s'arrête à la plus courte (comportement zip).

Opérateurs de Comparaison

Map (retourne booléens)

# Supérieur
x.[> 0]        # retourne [True/False pour chaque x]

# Inférieur
x.[< 100]      # retourne masque booléen

# Égalité
x.[== 42]      # retourne masque booléen

# Différent
x.[!= 0]       # retourne masque booléen

Filter (retourne éléments)

# Filtrer éléments supérieurs à 0
x.[if > 0]     # retourne seulement les éléments > 0

# Filtrer éléments inférieurs à 100
x.[if < 100]   # retourne seulement les éléments < 100

# Filtrer éléments égaux à 42
x.[if == 42]   # retourne seulement les 42

# Filtrer éléments différents de 0
x.[if != 0]    # retourne seulement les non-zéros

Fonctions Unaires

abs.[x]        # abs(x)
sqrt.[x]       # sqrt(x)
log.[x]        # log(x)
exp.[x]        # exp(x)
round.[x]      # round(x)

Lambdas

# Lambda simple
((x) => { x * 2 }).[data]

# Lambda complexe
((x) => {
    if x > 0 {
        x * 2
    } else {
        0
    }
}).[data]

Décisions Prises

1. Syntaxe Finale

Choix : A.[op M] (opération à droite)

Raisons :

  • Chaînage naturel
  • Style orienté données
  • Familier pour utilisateurs pandas

2. Priorité des Opérateurs

Le broadcasting a la priorité d'accès membre (.)

2 + data.[* 3]  # = 2 + (data.[* 3])

Équivalent à :

2 + data.method()  # Priorité de . avant +

3. Type Safety et Erreurs

Les erreurs Python sont propagées

Si l'opération échoue sur un élément, une exception Python est levée.

data = list("a", "b", "c")
result = data.[+ 2]  # TypeError: can only concatenate str (not "int") to str

Comportement par Type de Collection

Le broadcast traite les types de données différemment selon leur nature :

Type Comportement Résultat
list Itération + descente récursive list
tuple Itération + descente récursive tuple
set Itération (ordre non garanti) list
dict Itération sur les clés list
range Consommé comme itérable list
str Scalaire (pas d'itération caractère) Résultat scalaire
bytes Scalaire Résultat scalaire
struct Scalaire Résultat scalaire
scalaire Opération directe Résultat scalaire

Seuls list et tuple bénéficient de la descente récursive et de la préservation du type. Les autres itérables sont consommés à plat et produisent une list.

# set : itéré, résultat en list
set(10, 20, 30).[* 2]         # [20, 40, 60]

# dict : itère sur les clés (strings), pas les valeurs
dict(a=1).[* 3]                # ["aaa"]

# range : consommé comme itérable
range(4).[+ 10]               # [10, 11, 12, 13]

# string : scalaire, pas itéré caractère par caractère
"hello".[* 3]                  # "hellohellohello"

Les dicts itèrent sur les clés, pas les valeurs. C'est le comportement standard de for k in dict en Python. Si on veut les valeurs, utiliser dict.values() explicitement.

Broadcast Récursif sur Structures Imbriquées

Le broadcast descend automatiquement dans les listes et tuples imbriqués jusqu'aux feuilles scalaires. L'opération n'est appliquée qu'aux valeurs terminales ; la structure (shape) est préservée.

# Broadcast deep : descente automatique
matrix = list(list(1, 2), list(3, 4))
matrix.[* 2]  # [[2, 4], [6, 8]]

# Profondeur mixte
list(1, list(2, 3)).[+ 10]  # [11, [12, 13]]

# Tensor 3D
cube = list(
    list(list(1, 2), list(3, 4)),
    list(list(5, 6), list(7, 8))
)
cube.[+ 10]  # [[[11, 12], [13, 14]], [[15, 16], [17, 18]]]

Les opérateurs ND (~> et ~~) suivent la même sémantique de descente implicite :

# ND-map implicite
matrix = list(list(-1, 2), list(-3, 4))
matrix.[~> abs]  # [[1, 2], [3, 4]]

# ND-recursion implicite
nums = list(3, 5)
nums.[~~ (n, recur) => { if n <= 1 { 1 } else { n * recur(n - 1) } }]  # [6, 120]

Forme Explicite

La forme .[.[...]] reste valide pour exprimer une descente manuelle niveau par niveau, mais n'est plus nécessaire puisque le broadcast descend automatiquement.

Spécification Minimale (Règles Formelles)

  1. Descente implicite : le broadcast parcourt récursivement la structure jusqu'aux feuilles scalaires.
  2. Arrêt récursif : une feuille est un scalaire (int, float, str, bool, None), un struct, ou une valeur non-itérable.
  3. Préservation de structure : le shape (imbrication, cardinalités, type de conteneur) est conservé ; seules les feuilles changent.
  4. Composition : pour fonctions pures, A.[f].[g] == A.[(x) => { g(f(x)) }].
  5. Déterminisme : pour fonctions pures, l'ordre de parcours interne n'affecte pas le résultat.
# Invariant 3: shape conservé
list(list(1, 2), list(3, 4)).[~> (x) => { x * 10 }]
# -> [[10, 20], [30, 40]]

# Invariant 4: composition
A.[~> f].[~> g] == A.[~> (x) => { g(f(x)) }]

Garanties Recherchées (pour ND implicite)

Naturalité : Le résultat ne dépend pas de l'ordre de traitement des dimensions

  • Parcourir par ligne puis colonne donne le même résultat que colonne puis ligne
  • La profondeur de récursion est déterminée par la structure des données
  • Pas de dépendance sur l'implémentation interne

Composition : Les opérations se composent de manière prévisible

  • A.[f].[g] équivaut à A.[(x) => { g(f(x)) }] pour toute profondeur
  • Pas d'effet de bord entre opérations successives
  • Une seule forme de code pour les cas simples et complexes

Préservation de structure : Le "shape" du tensor reste identique

  • La forme du tensor (dimensions, imbrication) est préservée
  • Nombre de dimensions constant
  • Seules les valeurs scalaires changent