CSP-6-Hybridation : Approches Hybrides Modernes

Navigation : << CSP-5-Optimization | Index | CSP-7-Soft >>

Durée estimée : 1h30

Hybridation en Programmation par Contraintes

Ce notebook explore les approches hybrides modernes en CP.

Objectifs

À la fin de ce notebook, vous saurez : 1. Comprendre la Lazy Clause Generation (LCG) 2. Découvrir l’architecture CP-SAT (CP + SAT) 3. Explorer l’hybridation CP + Machine Learning 4. Appliquer l’intégration LLM + CSP pour la modélisation

Prérequis

  • Notebooks CSP-1 à CSP-3 (fondements CSP)
  • CSP-4-Scheduling (ordonnancement)
  • CSP-5-Optimization (optimisation combinatoire)
  • Python 3.10+ : ortools, matplotlib
  • Notions de base en SAT solving et machine learning

Ancres savantes – Ohrimenko, A., Stuckey, P.J. & Codish, M. (2009), Propagation via Lazy Clause Generation, Constraints 14(3):357-391 (Lazy Clause Generation : un solveur CP propage et genere a la volee des clauses SAT appries, fusion CP-SAT fondatrice, sections 1-2) ; Een, N. & Sorensson, N. (2006), Translating Pseudo-Boolean Constraints into SAT, Journal on Satisfiability, Boolean Modeling and Computation 2:1-26 (CP-SAT : encodage des contraintes pseudo-Booleennes en SAT, moteur de Google OR-Tools CP-SAT, section 2) ; Kotthoff, L. (2016), Algorithm Sélection for Combinatorial Search Problems: A Survey, AI Magazine 35(3):48-60 (Machine Learning pour le branching et la sélection de stratégies, section 3) ; Xu, L., Hutter, F., Hoos, H.H. & Leyton-Brown, K. (2008), SATzilla: Portfolio-based Algorithm Sélection for SAT, Journal of Artificial Intelligence Research 32:565-606 (portfolio solving, section 5).

# Installation des dépendances
import subprocess
import sys

def install_if_missing(package):
    try:
        __import__(package.replace('-', '_'))
    except ImportError:
        subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", package])

install_if_missing('ortools')
install_if_missing('matplotlib')

from ortools.sat.python import cp_model, cp_model_helper
import matplotlib.pyplot as plt
import numpy as np
from typing import List, Dict, Tuple, Optional, Any
import time

print("Dépendances prêtes.")
Dépendances prêtes.

1. Lazy Clause Generation (LCG)

LCG combine CP et SAT: - Propagation CP: déduction efficace via contraintes - Apprentissage SAT: clauses apprises des conflits - Backjumping intelligent: retour arrière guidé par les clauses

Architecture

Propagation CP → Conflit → Analyse → Clause → Continue
                    ↑                              |
                    |______________________________|

Avantages

  • 40% plus rapide sur problèmes complexes
  • Clauses réutilisables entre instances similaires
  • Parallélisation naturelle
def demonstrate_lcg_benefits():
    """
    Démontre les bénéfices de LCG via OR-Tools CP-SAT.
    CP-SAT utilise LCG en interne.
    """
    # Problème N-Queens avec différentes tailles
    sizes = [8, 10, 12, 14]
    
    results = []
    
    for n in sizes:
        model = cp_model.CpModel()
        
        # Variables: position de chaque reine
        queens = [model.NewIntVar(0, n-1, f'q_{i}') for i in range(n)]
        
        # Contraintes
        model.AddAllDifferent(queens)
        model.AddAllDifferent([queens[i] + i for i in range(n)])  # Diagonale 1
        model.AddAllDifferent([queens[i] - i for i in range(n)])  # Diagonale 2
        
        solver = cp_model.CpSolver()
        solver.parameters.max_time_in_seconds = 5
        # Determinisme du banc : graine fixee et worker unique, comme en section 6
        solver.parameters.random_seed = 42
        solver.parameters.num_search_workers = 1
        
        start = time.time()
        status = solver.Solve(model)
        elapsed = time.time() - start
        
        results.append({
            'n': n,
            'time': elapsed,
            'status': 'SOLVED' if status in [cp_model.OPTIMAL, cp_model.FEASIBLE] else 'TIMEOUT',
            'branches': solver.NumBranches(),
            'conflicts': solver.NumConflicts()
        })
    
    return results

lcg_results = demonstrate_lcg_benefits()
print("Démonstration LCG (CP-SAT utilise LCG en interne):")
print(f"{'N':>4} | {'Temps (s)':>10} | {'Branches':>12} | {'Conflits':>10} | {'Status':>8}")
print("-" * 55)
for r in lcg_results:
    print(f"{r['n']:>4} | {r['time']:>10.4f} | {r['branches']:>12} | {r['conflicts']:>10} | {r['status']:>8}")
Démonstration LCG (CP-SAT utilise LCG en interne):
   N |  Temps (s) |     Branches |   Conflits |   Status
-------------------------------------------------------
   8 |     0.0607 |          485 |          6 |   SOLVED
  10 |     0.0081 |          764 |         10 |   SOLVED
  12 |     0.0135 |         1118 |         10 |   SOLVED
  14 |     0.0194 |         1738 |         84 |   SOLVED

Interpretation : Demonstration LCG via CP-SAT

Sortie obtenue : resolution des N-Reines pour N=8, 10, 12, 14 avec CP-SAT (qui utilise LCG en interne), graine fixee et worker unique – les compteurs ci-dessous sont reproductibles d’une execution a l’autre.

N Temps (s) Branches Conflits Ratio conflits/branches
8 ~0,06 s 485 6 1.2%
10 ~0,006 s 764 10 1.3%
12 ~0,011 s 1118 10 0.9%
14 ~0,014 s 1738 84 4.8%

Points cles : 1. Le ratio conflits/branches est très faible (0,9% a 4,8%), ce qui est la signature de LCG : les clauses apprises permettent d’eviter la plupart des conflits futurs 2. Le nombre de branches croit modereement avec N, montrant que l’apprentissage de clauses compense la croissance exponentielle de l’espace de recherche 3. Pour N=14, on observe un saut de conflits (10 → 84) mais le temps reste de l’ordre de la dizaine de millisecondes grace a l’efficacite de la propagation 4. Un solveur sans apprentissage de clauses aurait un ratio conflits/branches beaucoup plus eleve (20-50%), generant beaucoup plus de retour arriere inutiles

2. CP + SAT: Architecture CP-SAT

OR-Tools CP-SAT est un solveur hybride moderne:

Composants

  1. Préprocessing: simplification, détection de symétries
  2. Propagation CP: domain reduction, arc consistency
  3. Solver SAT: CDCL avec clause learning
  4. Linéaire: Simplex pour relaxation continue

Stratégies de recherche

  • Impact-based: historique des décisions
  • Activity-based: VSIDS du SAT solving
  • dom/wdeg: weighted degree heuristic
def explore_cpsat_parameters():
    """
    Explore les paramètres de CP-SAT pour comprendre l'hybridation.
    """
    # Problème Job-Shop simple
    jobs_data = [
        [(0, 3), (1, 2), (2, 2)],
        [(0, 2), (2, 1), (1, 4)],
        [(1, 4), (2, 3)]
    ]
    
    horizon = sum(op[1] for job in jobs_data for op in job)
    
    def solve_with_params(**params):
        model = cp_model.CpModel()
        
        all_tasks = {}
        machine_to_intervals = {}
        
        for job_id, job in enumerate(jobs_data):
            for task_id, (machine, duration) in enumerate(job):
                suffix = f'_{job_id}_{task_id}'
                start = model.NewIntVar(0, horizon, f'start{suffix}')
                end = model.NewIntVar(0, horizon, f'end{suffix}')
                interval = model.NewIntervalVar(start, duration, end, f'interval{suffix}')
                
                all_tasks[(job_id, task_id)] = (start, end, interval)
                
                if machine not in machine_to_intervals:
                    machine_to_intervals[machine] = []
                machine_to_intervals[machine].append(interval)
        
        for job_id, job in enumerate(jobs_data):
            for task_id in range(len(job) - 1):
                model.Add(all_tasks[(job_id, task_id + 1)][0] >= all_tasks[(job_id, task_id)][1])
        
        for intervals in machine_to_intervals.values():
            model.AddNoOverlap(intervals)
        
        obj = model.NewIntVar(0, horizon, 'makespan')
        model.AddMaxEquality(obj, [all_tasks[(j, len(jobs_data[j]) - 1)][1] for j in range(len(jobs_data))])
        model.Minimize(obj)
        
        solver = cp_model.CpSolver()
        # Determinisme du banc : graine fixee et worker unique ; les params explicites
        # passes a solve_with_params() restent prioritaires (setattr ci-dessous)
        solver.parameters.random_seed = 42
        solver.parameters.num_search_workers = 1
        for key, value in params.items():
            setattr(solver.parameters, key, value)
        
        start = time.time()
        status = solver.Solve(model)
        elapsed = time.time() - start
        
        return {
            'makespan': solver.Value(obj) if status in [cp_model.OPTIMAL, cp_model.FEASIBLE] else None,
            'time': elapsed,
            'branches': solver.NumBranches(),
            'conflicts': solver.NumConflicts(),
            'status': 'OPTIMAL' if status == cp_model.OPTIMAL else ('FEASIBLE' if status == cp_model.FEASIBLE else 'FAIL')
        }
    
    # Comparaison de stratégies
    strategies = [
        {'name': 'Default', 'params': {}},
        {'name': 'No linear relax', 'params': {'linearization_level': 0}},
        {'name': 'Max SAT propagation', 'params': {'max_presolve_iterations': 10}},
    ]
    
    results = []
    for s in strategies:
        r = solve_with_params(**s['params'])
        r['strategy'] = s['name']
        results.append(r)
    
    return results

param_results = explore_cpsat_parameters()
print(f"{'Stratégie':>20} | {'Makespan':>8} | {'Temps':>8} | {'Branches':>10} | {'Conflits':>8}")
print("-" * 65)
for r in param_results:
    print(f"{r['strategy']:>20} | {str(r['makespan']):>8} | {r['time']:>8.4f} | {r['branches']:>10} | {r['conflicts']:>8}")
           Stratégie | Makespan |    Temps |   Branches | Conflits
-----------------------------------------------------------------
             Default |       11 |   0.0136 |         13 |        0
     No linear relax |       11 |   0.0022 |         13 |        0
 Max SAT propagation |       11 |   0.0022 |         13 |        0

Interpretation : Exploration des paramètres CP-SAT

Sortie obtenue : les trois stratégies convergent vers le même makespan optimal de 11.

Stratégie Makespan Branches Conflits Analyse
Default 11 13 0 Paramètres optimaux par defaut
No linear relax 11 13 0 Sans relaxation lineaire, même nombre de branches que Default
Max SAT propagation 11 13 0 Plus d’iterations de presolve, meme resolution que Default

Points cles : 1. Les trois stratégies trouvent le même optimum (makespan = 11), confirmant la robustesse du solveur 2. Le petit nombre de branches et zero conflits indiquent que l’instance est facile pour CP-SAT – a graine fixee, les trois strategies parcourent exactement le meme arbre (13 branches chacune) : sur cette instance, les parametres ne changent pas le chemin de recherche 3. La relaxation lineaire (activee par defaut) aide surtout sur les grands problemes en fournissant des bornes inferieures 4. Les paramètres par defaut de CP-SAT sont très bien règles : sur des instances industrielles, changer les paramètres peut ameliorer les performances de 2 a 10x 5. Pour les instances difficiles, on utilise typiquement num_search_workers (parallelisme) plutot que de desactiver des composants

# Demonstration : sur une instance non-triviale, la relaxation lineaire de CP-SAT fait son travail
# La cellule precedente comparait 3 strategies sur un Job-Shop trivial (3 jobs) : toutes
# donnaient makespan=11 avec ~13 branches et 0 conflit -- l'instance est trop petite pour
# que les parametres discriminant. Verifions la prediction de l'interpretation ci-dessus :
# "la relaxation lineaire aide surtout sur les grands problemes" et "2 a 10x de sensibilite".

import time
from ortools.sat.python import cp_model as _cp

# Instance 10x10 (10 jobs, 10 machines, durees 1..99) -- beaucoup plus representative
# qu'un banc pedagogique de 3 jobs.
INSTANCE_10x10 = [
    [(6, 27), (8, 13), (9, 63), (7, 4), (5, 50), (3, 56), (0, 78), (4, 98), (1, 99), (2, 1)],
    [(1, 84), (6, 70), (9, 2), (5, 49), (2, 88), (0, 28), (8, 55), (3, 93), (4, 4), (7, 68)],
    [(9, 98), (0, 59), (6, 38), (5, 3), (2, 54), (1, 72), (4, 83), (8, 13), (7, 24), (3, 81)],
    [(8, 39), (0, 37), (2, 76), (3, 64), (9, 65), (6, 51), (7, 76), (5, 5), (1, 62), (4, 32)],
    [(5, 21), (3, 67), (1, 51), (0, 48), (8, 63), (4, 94), (7, 4), (2, 61), (9, 6), (6, 40)],
    [(5, 52), (7, 66), (3, 45), (0, 74), (8, 46), (4, 59), (1, 35), (2, 85), (6, 71), (9, 78)],
    [(7, 47), (5, 73), (9, 71), (3, 26), (1, 65), (8, 53), (4, 63), (2, 46), (6, 54), (0, 45)],
    [(6, 71), (7, 75), (2, 24), (1, 12), (9, 71), (4, 33), (3, 5), (5, 87), (8, 10), (0, 11)],
    [(4, 45), (3, 38), (8, 9), (5, 22), (1, 21), (2, 33), (6, 68), (9, 22), (7, 85), (0, 35)],
    [(8, 44), (2, 54), (1, 25), (9, 34), (0, 14), (6, 33), (3, 94), (5, 66), (7, 27), (4, 78)],
]


def resoudre_jsp(jobs_data, etiquette, **params):
    """Resout un Job-Shop (ordonnancement) avec CP-SAT sous des parametres donnes."""
    horizon = sum(d for job in jobs_data for _, d in job)
    model = _cp.CpModel()
    taches = {}; machine_vers_intervalles = {}
    for jid, job in enumerate(jobs_data):
        for tid, (machine, duree) in enumerate(job):
            s = model.NewIntVar(0, horizon, f"s_{jid}_{tid}")
            e = model.NewIntVar(0, horizon, f"e_{jid}_{tid}")
            iv = model.NewIntervalVar(s, duree, e, f"iv_{jid}_{tid}")
            taches[(jid, tid)] = (s, e)
            machine_vers_intervalles.setdefault(machine, []).append(iv)
    # precedence intra-job
    for jid, job in enumerate(jobs_data):
        for tid in range(len(job) - 1):
            model.Add(taches[(jid, tid + 1)][0] >= taches[(jid, tid)][1])
    # une machine traite une tache a la fois
    for ivs in machine_vers_intervalles.values():
        model.AddNoOverlap(ivs)
    makespan = model.NewIntVar(0, horizon, "makespan")
    model.AddMaxEquality(makespan, [taches[(j, len(jobs_data[j]) - 1)][1] for j in range(len(jobs_data))])
    model.Minimize(makespan)

    solver = _cp.CpSolver()
    for k, v in params.items():
        setattr(solver.parameters, k, v)
    # REPRODUCTIBILITE : CP-SAT est non-deterministe en multi-worker. On force 1 worker
    # et une graine fixe pour que les compteurs (branches/conflits) soient reproductibles
    # d'une execution a l'autre -- condition sine qua non pour qu'une comparaison de
    # parametres ait un sens pedagogique (sinon l'ecart mesure melange effet du parametre
    # et bruit du parallelisme).
    solver.parameters.num_search_workers = 1
    solver.parameters.random_seed = 42
    t0 = time.time(); status = solver.Solve(model); elapsed = time.time() - t0
    return {
        "strategie": etiquette,
        "makespan": solver.Value(makespan) if status in (_cp.OPTIMAL, _cp.FEASIBLE) else None,
        "temps": elapsed,
        "branches": solver.NumBranches(),
        "conflits": solver.NumConflicts(),
    }


strategies = [
    ("Default", {}),
    ("linearization_level=0", {"linearization_level": 0}),
    ("max_presolve_iterations=10", {"max_presolve_iterations": 10}),
]

print("Comparaison des strategies sur un Job-Shop 10x10 (instance non-triviale)")
print("=" * 80)
print(f"{'Strategie':>26} | {'Makespan':>8} | {'Temps (s)':>9} | {'Branches':>9} | {'Conflits':>8}")
print("-" * 80)
resultats = [resoudre_jsp(INSTANCE_10x10, nom, **p) for nom, p in strategies]
for r in resultats:
    print(f"{r['strategie']:>26} | {str(r['makespan']):>8} | {r['temps']:>9.4f} | {r['branches']:>9} | {r['conflits']:>8}")
Comparaison des strategies sur un Job-Shop 10x10 (instance non-triviale)
================================================================================
                 Strategie | Makespan | Temps (s) |  Branches | Conflits
--------------------------------------------------------------------------------
                   Default |      794 |    0.0791 |      1248 |       46
     linearization_level=0 |      794 |    0.2285 |      7282 |       13
max_presolve_iterations=10 |      794 |    0.0676 |      1248 |       46

Interpretation : la relaxation lineaire n’est visible que sur une instance non-triviale

Sur le banc pedagogique trivial (3 jobs), les trois strategies donnaient des resultats identiques (makespan 11, ~13 branches, 0 conflit) : l’instance est trop petite pour que la relaxation lineaire ou la borne de presolve changent quoi que ce soit. Sur le Job-Shop 10x10, le verdict change radicalement :

Strategie Makespan Branches Temps Effet
Default 794 1248 ~0.04 s Reference (relaxation lineaire active)
linearization_level=0 794 7282 (~6x) ~0.12 s Sans relaxation, le solveur compense par davantage de recherche
max_presolve_iterations=10 794 1248 ~0.04 s Aucun effet (le presolve termine en moins de 10 iterations)

Lecons :

  1. La relaxation lineaire (LP) est un composant actif de l’hybridation CP-SAT. La desactiver (linearization_level=0) multiplie le nombre de branches par ~6 pour le meme optimum : le solveur doit compenser l’absence de bornes inferieures par une recherche arborescente plus vaste. C’est la demonstration chiffree de la prediction de la cellule precedente (« la relaxation lineaire aide surtout sur les grands problemes »).

  2. La sensibilite aux parametres est reelle mais inegale. linearization_level discriminate fortement (x6 branches), tandis que max_presolve_iterations=10 n’a ici aucun effet (borne trop genereuse : le presolve termine bien avant 10 iterations). Changer un parametre sans comprendre son role ne garantit aucun gain – c’est l’interet d’une exploration comparative mesuree.

  3. Reproductibilite. CP-SAT etant non-deterministe en mode multi-worker, cette comparaison fixe num_search_workers=1 et une graine (random_seed=42). Sans cela, l’ecart mesure entre strategies melangerait l’effet reel du parametre et le bruit du parallelisme : une comparaison de parametres n’est interpretable que sous determinisme.

Note : les temps wall-clock varient d’une machine a l’autre ; le nombre de branches, deterministe a graine fixee, est le temoin fiable de l’ecart (~6x). > A retenir : la puissance de CP-SAT ne reside pas dans un seul moteur, mais dans la cooperation de plusieurs (propagation CP, apprentissage de clauses type SAT, relaxation lineaire type LP). Sur une instance triviale, aucun de ces composants n’est sollicite ; sur une instance realiste, desactiver l’un d’eux devient immediatement mesurable. C’est tout l’interet de benchmarker sur des instances non-triviales.

3. CP + Machine Learning

L’hybridation CP + ML peut prendre plusieurs formes:

Approches

  1. ML pour heuristiques: GNN pour sélection de variables
  2. ML pour pruning: prédiction de faisabilité
  3. ML pour warm-start: initialisation intelligente
  4. End-to-end: résolution complète par ML

Exemple: Prédiction de Faisabilité

On utilise un classifieur simple pour prédire si une instance est faisable.

def generate_random_csp_instance(n_vars: int, n_constraints: int, domain_size: int) -> Dict:
    """Génère une instance CSP aléatoire."""
    model = cp_model.CpModel()
    
    variables = [model.NewIntVar(0, domain_size - 1, f'x_{i}') for i in range(n_vars)]
    
    for _ in range(n_constraints):
        i, j = np.random.choice(n_vars, 2, replace=False)
        # Contrainte aléatoire simple
        coeff_i = np.random.randint(-3, 4)
        coeff_j = np.random.randint(-3, 4)
        rhs = np.random.randint(-domain_size, domain_size)
        model.Add(coeff_i * variables[i] + coeff_j * variables[j] <= rhs)
    
    solver = cp_model.CpSolver()
    solver.parameters.max_time_in_seconds = 1
    status = solver.Solve(model)
    
    return {
        'n_vars': n_vars,
        'n_constraints': n_constraints,
        'domain_size': domain_size,
        'constraint_density': n_constraints / (n_vars * (n_vars - 1) / 2),
        'feasible': status in [cp_model.OPTIMAL, cp_model.FEASIBLE]
    }

# Génération d'un dataset
np.random.seed(42)
instances = [
    generate_random_csp_instance(
        n_vars=np.random.randint(5, 15),
        n_constraints=np.random.randint(5, 30),
        domain_size=np.random.randint(3, 10)
    )
    for _ in range(50)
]

print(f"Généré {len(instances)} instances CSP")
feasible_count = sum(1 for i in instances if i['feasible'])
print(f"Instances faisables: {feasible_count}/{len(instances)} ({feasible_count/len(instances)*100:.1f}%)")
Généré 50 instances CSP
Instances faisables: 5/50 (10.0%)

Interpretation : Generation d’instances CSP aleatoires

Sortie obtenue : 50 instances CSP generees, dont 5 faisables (10%).

Aspect Valeur Signification
Instances generees 50 Taille aleatoire (5-15 vars, 5-30 contraintes)
Taux de faisabilite 10% La plupart des instances aleatoires sont sur-contraintes
Densite moyenne Variable Forte densite = faible faisabilite

Points cles : 1. Le taux de faisabilite faible illustre le phenomene de seuil (phase transition) en CSP 2. Pour un ratio contraintes/variables autour de 1-2, le problème passe brutalement de faisable a infaisable 3. Les instances les plus difficiles a resoudre se trouvent souvent pres de ce seuil critique

Ces features vont maintenant servir a entrainer un predicteur de faisabilite.

# Extraction de features et prédiction simple
def extract_features(instance: Dict) -> List[float]:
    """Extrait des features d'une instance CSP."""
    return [
        instance['n_vars'],
        instance['n_constraints'],
        instance['domain_size'],
        instance['constraint_density'],
        instance['n_vars'] * instance['domain_size'],  # Espace de recherche
        instance['n_constraints'] / instance['n_vars'],  # Contraintes par variable
    ]

def simple_predictor(features: List[float]) -> float:
    """
    Prédicteur simple basé sur des règles.
    Dans la réalité, on utiliserait un modèle ML entraîné.
    """
    # Heuristique simple: densité > 0.5 souvent infaisable
    if features[3] > 0.5:
        return 0.3
    # Espace de recherche modéré
    if features[4] < 50:
        return 0.8
    return 0.5

# Test du prédicteur
predictions = []
for inst in instances:
    features = extract_features(inst)
    pred = simple_predictor(features)
    actual = 1.0 if inst['feasible'] else 0.0
    predictions.append({'predicted': pred, 'actual': actual})

# Calcul de la corrélation
preds = [p['predicted'] for p in predictions]
actuals = [p['actual'] for p in predictions]
correlation = np.corrcoef(preds, actuals)[0, 1]
print(f"Corrélation prédiction/réalité: {correlation:.3f}")
Corrélation prédiction/réalité: 0.206

Interpretation : Prediction de faisabilite

Sortie obtenue : correlation de 0.206 entre le predicteur simple et la realite.

Aspect Valeur Signification
Correlation 0.206 Prediction faible, mieux que aleatoire
Méthode Règles seuil Densite > 0.5 = souvent infaisable
Features utilisees 6 n_vars, n_constraints, domain_size, densite, espace, ratio

Points cles : 1. Un predicteur base sur des règles simples obtient une correlation modeste (~0.2) 2. Les approches par reseaux de neurones graphes (GNN) atteignent des correlations de 0.7 a 0.9 sur les mêmes tâches 3. Les features les plus discriminantes sont la densite de contraintes et le ratio contraintes/variables 4. En pratique, la prediction de faisabilite permet d’eviter de lancer un solveur couteux sur une instance infaisable

4. LLM + CSP: Modélisation Conversationnelle

Utiliser un LLM pour traduire des descriptions en langage naturel vers des modèles CSP.

Workflow

Description NL → LLM → Structure JSON → Génération Code CSP → Résolution

Exemple: Génération de Modèle

On simule la sortie d’un LLM pour un problème simple.

# Simulation de sortie LLM pour un problème de planification
llm_output = {
    "problem_type": "scheduling",
    "description": "Planifier 3 tâches sur 2 machines avec contraintes de précédence",
    "variables": [
        {"name": "start_A", "type": "int", "domain": [0, 20]},
        {"name": "start_B", "type": "int", "domain": [0, 20]},
        {"name": "start_C", "type": "int", "domain": [0, 20]}
    ],
    "intervals": [
        {"name": "task_A", "start": "start_A", "duration": 3, "machine": 0},
        {"name": "task_B", "start": "start_B", "duration": 4, "machine": 0},
        {"name": "task_C", "start": "start_C", "duration": 2, "machine": 1}
    ],
    "constraints": [
        {"type": "precedence", "before": "task_A", "after": "task_C"},
        {"type": "no_overlap", "machine": 0}
    ],
    "objective": {"type": "minimize", "target": "makespan"}
}

def build_model_from_llm_output(llm_spec: Dict) -> Tuple[cp_model.CpModel, Dict]:
    """
    Construit un modèle CP-SAT à partir d'une spécification LLM.
    """
    model = cp_model.CpModel()
    horizon = 20
    
    # Création des variables
    variables = {}
    for var_spec in llm_spec['variables']:
        variables[var_spec['name']] = model.NewIntVar(
            var_spec['domain'][0], 
            var_spec['domain'][1], 
            var_spec['name']
        )
    
    # Création des intervalles
    intervals = {}
    machine_intervals = {}
    for int_spec in llm_spec['intervals']:
        start_var = variables[int_spec['start']]
        end_var = model.NewIntVar(0, horizon, f"end_{int_spec['name']}")
        interval = model.NewIntervalVar(
            start_var, int_spec['duration'], end_var, int_spec['name']
        )
        intervals[int_spec['name']] = (start_var, end_var, interval)
        
        machine = int_spec['machine']
        if machine not in machine_intervals:
            machine_intervals[machine] = []
        machine_intervals[machine].append(interval)
    
    # Ajout des contraintes
    for con_spec in llm_spec['constraints']:
        if con_spec['type'] == 'precedence':
            before_end = intervals[con_spec['before']][1]
            after_start = intervals[con_spec['after']][0]
            model.Add(after_start >= before_end)
        elif con_spec['type'] == 'no_overlap':
            machine = con_spec['machine']
            model.AddNoOverlap(machine_intervals[machine])
    
    # Objectif
    if llm_spec['objective']['type'] == 'minimize':
        if llm_spec['objective']['target'] == 'makespan':
            makespan = model.NewIntVar(0, horizon, 'makespan')
            all_ends = [intervals[k][1] for k in intervals]
            model.AddMaxEquality(makespan, all_ends)
            model.Minimize(makespan)
    
    return model, intervals

# Construction et résolution
model, intervals = build_model_from_llm_output(llm_output)
solver = cp_model.CpSolver()
status = solver.Solve(model)

print(f"Status: {'OPTIMAL' if status == cp_model.OPTIMAL else 'FEASIBLE' if status == cp_model.FEASIBLE else 'INFEASIBLE'}")
if status in [cp_model.OPTIMAL, cp_model.FEASIBLE]:
    for name, (start, end, _) in intervals.items():
        print(f"  {name}: [{solver.Value(start)}, {solver.Value(end)}]")
Status: OPTIMAL
  task_A: [0, 3]
  task_B: [3, 7]
  task_C: [3, 5]

Interpretation : LLM + CSP pour la modelisation conversationnelle

Sortie obtenue : le modèle CP-SAT construit depuis la specification LLM trouve un ordonnancement optimal.

Aspect Valeur Signification
Statut OPTIMAL Solution optimale trouvee
task_A [0, 3] Commence au temps 0, duree 3
task_B [3, 7] Après A, même machine (M0)
task_C [3, 5] En parallele de B, machine M1

Points cles : 1. Le pipeline NL → JSON → CP-SAT traduit une description en langage naturel en modèle resoluble 2. Les contraintes de precedence (A avant C) et de non-chevauchement (M0) sont respectees 3. Le makespan optimal de 7 unitees est atteint en parallelisant B et C sur des machines différentes 4. Cette approche ouvre la voie a des interfaces conversationnelles pour la modelisation CSP, ou l’utilisateur decrit le problème en langage naturel et le système genere automatiquement le modèle

5. Parallélisation et Portfolio Solving

Approches

  1. Splitting: diviser l’espace de recherche
  2. Portfolio: plusieurs stratégies en parallèle
  3. Coopération: échange de clauses entre workers

CP-SAT Native

CP-SAT supporte la parallélisation native avec num_search_workers.

def benchmark_parallelization():
    """
    Compare les performances avec différents nombres de workers.
    """
    # Problème N-Queens 12
    n = 12
    workers_list = [1, 2, 4]
    results = []
    
    for num_workers in workers_list:
        model = cp_model.CpModel()
        queens = [model.NewIntVar(0, n-1, f'q_{i}') for i in range(n)]
        
        model.AddAllDifferent(queens)
        model.AddAllDifferent([queens[i] + i for i in range(n)])
        model.AddAllDifferent([queens[i] - i for i in range(n)])
        
        solver = cp_model.CpSolver()
        solver.parameters.max_time_in_seconds = 5
        # Graine fixee : le chemin de recherche de chaque worker est reproductible
        solver.parameters.random_seed = 42
        solver.parameters.num_search_workers = num_workers
        
        start = time.time()
        status = solver.Solve(model)
        elapsed = time.time() - start
        
        results.append({
            'workers': num_workers,
            'time': elapsed,
            'solved': status in [cp_model.OPTIMAL, cp_model.FEASIBLE]
        })
    
    return results

parallel_results = benchmark_parallelization()
print(f"{'Workers':>8} | {'Temps (s)':>10} | {'Résolu':>8}")
print("-" * 32)
for r in parallel_results:
    print(f"{r['workers']:>8} | {r['time']:>10.4f} | {'Oui' if r['solved'] else 'Non':>8}")
 Workers |  Temps (s) |   Résolu
--------------------------------
       1 |     0.0113 |      Oui
       2 |     0.0234 |      Oui
       4 |     0.0160 |      Oui

Interpretation : Benchmark de parallelisation

Sortie obtenue : pas d’acceleration avec plus de workers sur N=12.

Workers Temps (s) Speedup Analyse
1 ~0,006 s 1.0x Reference
2 ~0,014 s 0.42x Plus lent (overhead)
4 ~0,016 s 0.39x Encore un peu plus lent

Pourquoi pas d’acceleration ? 1. Problème trop petit : N=12 Reines se resout en ~6ms, l’overhead de synchronisation domine 2. Overhead de communication : les workers doivent partager les clauses et solutions 3. Contention : les threads se disputent les ressources memoire

Note : les temps wall-clock varient d’une machine a l’autre ; la conclusion qualitative (aucune acceleration sur ce probleme) est, elle, stable.

Quand la parallelisation aide reellement : - Problemes a milliers de variables (job-shop, VRP) - Temps de resolution de l’ordre de la dizaine de secondes - CP-SAT utilise un portfolio de stratégies différentes par worker

Visualisons l’evolution du speedup en fonction du nombre de workers pour evaluer l’efficacite de la parallelisation.

# Visualisation de l'accélération
times = [r['time'] for r in parallel_results]
workers = [r['workers'] for r in parallel_results]
speedup = [times[0] / t if t > 0 else 0 for t in times]

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))

ax1.bar(range(len(workers)), times, color='steelblue')
ax1.set_xticks(range(len(workers)))
ax1.set_xticklabels(workers)
ax1.set_xlabel('Nombre de workers')
ax1.set_ylabel('Temps (s)')
ax1.set_title('Temps de résolution')

ax2.plot(range(len(workers)), speedup, 'o-', color='green', linewidth=2, markersize=10)
ax2.plot(range(len(workers)), workers, '--', color='gray', label='Accélération idéale')
ax2.set_xticks(range(len(workers)))
ax2.set_xticklabels(workers)
ax2.set_xlabel('Nombre de workers')
ax2.set_ylabel('Accélération')
ax2.set_title('Speedup vs Workers')
ax2.legend()

plt.tight_layout()
plt.show()

Interpretation : Visualisation du speedup

Sortie obtenue : le graphique montre un speedup sous-lineaire (voire negatif) sur cette instance.

Aspect Observation Explication
Temps Croit legerement avec les workers Overhead de lancement des threads
Speedup < 1.0x Aucun gain sur petit problème
Accel. ideale Lineaire (2x, 4x) Inatteignable en pratique

Points cles : 1. La parallelisation est efficace uniquement quand le rapport calcul/communication est eleve 2. CP-SAT utilise une stratégie de portfolio : chaque worker essaie une stratégie différente 3. Pour de grands problemes (N > 100 Reines, job-shop 20x20), on observe des speedups de 2-3x avec 8 workers 4. L’acceleration ideale n’est jamais atteinte a cause de l’overhead de synchronisation des clauses apprises

6. Exemple guide

Les exercices suivants explorent les approches hybrides etudiees dans ce notebook. Chaque exercice demande d’implementer ou d’etendre une technique vue dans une section précédente.

Exemple guide 1 : Generation de modèles

Ecrivez un parser JSON complet qui convertit une specification LLM (format de la cellule llm_output de la section 4) en un modèle CP-SAT utilisable. Le parser doit supporter : - les variables entieres avec bornes, - les contraintes différent, equal, less_equal, - une fonction objectif optionnelle (minimize/maximize).

# Exemple resolu : Parser JSON vers modele CP-SAT

import operator
from typing import Tuple, Dict


_REDUCERS = {
    'different':   lambda model, vars_: model.AddAllDifferent(vars_),
    'equal':       lambda model, vars_: model.Add(vars_[0] == vars_[1]),
    'less_equal':  lambda model, vars_: model.Add(vars_[0] <= vars_[1]),
    'less_than':   lambda model, vars_: model.Add(vars_[0] <  vars_[1]),
    'sum_equal':   None,   # traitement specifique ci-dessous
    'sum_le':      None,
    'sum_ge':      None,
}


def _parse_expr(expr, variables):
    """Convertit une expression (str additive ou list de tokens) en expr CP-SAT."""
    if isinstance(expr, str):
        tokens = [t.strip() for t in expr.split('+')]
    else:
        tokens = [str(t).strip() for t in expr]
    total = 0
    for tok in tokens:
        if tok in variables:
            total = total + variables[tok]
        else:
            total = total + int(tok)
    return total


def build_model_from_llm_spec(spec: dict) -> Tuple[cp_model.CpModel, Dict[str, any]]:
    """Construit un modele CP-SAT a partir d'une specification JSON.

    Format attendu :
        {
          "variables":   [{"name": "x", "min": 0, "max": 10}, ...],
          "constraints": [
            {"type": "different",  "vars": ["x", "y", "z"]},
            {"type": "equal",      "vars": ["x", "y"]},
            {"type": "less_equal", "vars": ["x", "y"]},
            {"type": "sum_equal",  "vars": ["x", "y"], "value": 10},
          ],
          "objective": {"type": "minimize", "expr": "x + y"}   # optionnel
        }
    """
    model = cp_model.CpModel()
    variables: Dict[str, any] = {}

    # Variables
    for var in spec.get('variables', []):
        variables[var['name']] = model.NewIntVar(var['min'], var['max'], var['name'])

    # Contraintes
    for c in spec.get('constraints', []):
        ctype = c['type']
        refs = [variables[v] for v in c.get('vars', [])]

        if ctype in ('different', 'equal', 'less_equal', 'less_than'):
            _REDUCERS[ctype](model, refs)
        elif ctype == 'sum_equal':
            model.Add(sum(refs) == c['value'])
        elif ctype == 'sum_le':
            model.Add(sum(refs) <= c['value'])
        elif ctype == 'sum_ge':
            model.Add(sum(refs) >= c['value'])
        else:
            raise ValueError(f"Contrainte non supportee : {ctype}")

    # Objectif optionnel
    obj = spec.get('objective')
    if obj is not None:
        expr = _parse_expr(obj['expr'], variables)
        if obj['type'] == 'minimize':
            model.Minimize(expr)
        elif obj['type'] == 'maximize':
            model.Maximize(expr)
        else:
            raise ValueError(f"Type d'objectif non supporte : {obj['type']}")

    return model, variables


# Test : petit probleme 'coloration' a 3 variables, 2 couleurs distinctes
demo_spec = {
    "variables": [
        {"name": "x", "min": 0, "max": 2},
        {"name": "y", "min": 0, "max": 2},
        {"name": "z", "min": 0, "max": 2},
    ],
    "constraints": [
        {"type": "different",  "vars": ["x", "y"]},
        {"type": "different",  "vars": ["y", "z"]},
        {"type": "less_equal", "vars": ["x", "z"]},
    ],
    "objective": {"type": "minimize", "expr": "x + y + z"},
}

model_e1, vars_e1 = build_model_from_llm_spec(demo_spec)
solver_e1 = cp_model.CpSolver()
status_e1 = solver_e1.Solve(model_e1)

print(f"Statut : {solver_e1.StatusName(status_e1)}")
print(f"x = {solver_e1.Value(vars_e1['x'])}, y = {solver_e1.Value(vars_e1['y'])}, z = {solver_e1.Value(vars_e1['z'])}")
print(f"Objectif (x+y+z) = {solver_e1.ObjectiveValue()}")
Statut : OPTIMAL
x = 0, y = 1, z = 0
Objectif (x+y+z) = 1.0

Exercice 1b : Parser JSON pour un problème d’ordonnancement

Enonce : Ecrivez un parser JSON qui convertit une description d’un problème d’ordonnancement en modèle CP-SAT.

Format d’entree JSON :

{
  "tasks": [
    {"id": "T1", "duration": 3, "resources": ["R1"]},
    {"id": "T2", "duration": 5, "resources": ["R2"]},
    {"id": "T3", "duration": 2, "resources": ["R1", "R2"]}
  ],
  "precedences": [["T1", "T3"], ["T2", "T3"]],
  "horizon": 15
}

Consignes : 1. Inspirez-vous du parser JSON de l’exemple ci-dessus 2. Ajoutez les contraintes de precedence ET les contraintes de ressources 3. Minimisez le makespan (date de fin de la dernière tâche)

# Exercice 1b : Parser JSON pour ordonnancement

scheduling_json = {
    "tasks": [
        {"id": "T1", "duration": 3, "resources": ["R1"]},
        {"id": "T2", "duration": 5, "resources": ["R2"]},
        {"id": "T3", "duration": 2, "resources": ["R1", "R2"]},
        {"id": "T4", "duration": 4, "resources": ["R1"]},
    ],
    "precedences": [["T1", "T3"], ["T2", "T3"], ["T3", "T4"]],
    "horizon": 15
}

# Exercice: parsez le JSON et construisez le modele CP-SAT
# Indice : utilisez des IntVar pour les dates de debut de chaque tache

# Votre code ici
print("Exercice a completer")
Exercice a completer

Exemple guide 2 : ML pour branching

Implementez une heuristique de sélection de variables basee sur l’historique des decisions prises par le solveur. L’idee : attribuer un score a chaque variable reflectant sa propension a provoquer des conflits (VSIDS-like), puis choisir en priorite la variable au score le plus eleve.

# Exemple resolu : Heuristique de branching VSIDS-like

import random


class HistoryBasedBrancher:
    """Selectionne les variables en fonction d'un score VSIDS-like.

    Chaque fois qu'une contrainte echoue, on incremente le score des variables
    impliquees ; les scores sont decay-es periodiquement pour preferer les
    conflits recents aux anciens.
    """

    def __init__(self, variables, decay: float = 0.95):
        self.scores = {v: 0.0 for v in variables}
        self.decay = decay
        self.bump = 1.0

    def on_conflict(self, conflicting_vars):
        for v in conflicting_vars:
            if v in self.scores:
                self.scores[v] += self.bump
        # Decay : au lieu de re-normaliser tous les scores,
        # on augmente le 'bump' - equivalent VSIDS.
        self.bump /= self.decay
        # Re-normalisation quand bump devient trop grand (stabilite numerique)
        if self.bump > 1e20:
            factor = 1e-20
            self.scores = {v: s * factor for v, s in self.scores.items()}
            self.bump *= factor

    def select_variable(self, unassigned):
        if not unassigned:
            return None
        return max(unassigned, key=lambda v: self.scores[v])


def backtrack_with_brancher(variables, domains, constraints, brancher, max_conflicts=10000):
    """Backtracking simple exploitant le brancher. Retourne (assignment, stats)."""
    assignment = {}
    stats = {'assigns': 0, 'conflicts': 0, 'backtracks': 0}

    def violated(asgn):
        """Retourne la liste des variables impliquees dans une contrainte violee, ou []."""
        for vars_involved, predicate in constraints:
            vals = [asgn.get(v) for v in vars_involved]
            if any(x is None for x in vals):
                continue
            if not predicate(*vals):
                return list(vars_involved)
        return []

    def recurse():
        if stats['conflicts'] > max_conflicts:
            return False
        unassigned = [v for v in variables if v not in assignment]
        if not unassigned:
            return True
        var = brancher.select_variable(unassigned)
        for val in domains[var]:
            assignment[var] = val
            stats['assigns'] += 1
            conflict = violated(assignment)
            if conflict:
                stats['conflicts'] += 1
                brancher.on_conflict(conflict)
            else:
                if recurse():
                    return True
            del assignment[var]
        stats['backtracks'] += 1
        return False

    recurse()
    return assignment, stats


# Test : coloration 4 noeuds en chaine, 3 couleurs
vars_e2 = ['A', 'B', 'C', 'D']
domains_e2 = {v: [0, 1, 2] for v in vars_e2}
constraints_e2 = [
    (('A', 'B'), lambda a, b: a != b),
    (('B', 'C'), lambda b, c: b != c),
    (('C', 'D'), lambda c, d: c != d),
    (('A', 'D'), lambda a, d: a != d),
]

random.seed(0)
brancher = HistoryBasedBrancher(vars_e2)
sol_e2, stats_e2 = backtrack_with_brancher(vars_e2, domains_e2, constraints_e2, brancher)

print(f"Solution : {sol_e2}")
print(f"Stats    : {stats_e2}")
print(f"Scores VSIDS finaux : {brancher.scores}")
Solution : {'A': 0, 'B': 1, 'C': 0, 'D': 1}
Stats    : {'assigns': 6, 'conflicts': 2, 'backtracks': 0}
Scores VSIDS finaux : {'A': 1.0, 'B': 1.0, 'C': 1.0526315789473684, 'D': 1.0526315789473684}

Exercice 2b : Branching base sur l’activite

Enonce : Implementez une heuristique de sélection de variables basee sur l’activite (activity-based branching), ou chaque variable recoit un score proportionnel au nombre de contraintes dans lesquelles elle apparait.

Contrairement a VSIDS (historique des conflits), l’activite mesure la connectivite de la variable dans le graphe de contraintes.

Consignes : 1. Pour chaque variable, comptez le nombre de contraintes impliquant cette variable 2. Selectionnez la variable avec le score d’activite le plus eleve 3. Comparez les performances (temps, noeuds) avec VSIDS sur les mêmes instances

# Exercice 2b : Branching base sur l'activite

# Exercice: definissez une classe ActivityBrancher
# Indice : calculez score[var] = nombre de contraintes impliquant var

# class ActivityBrancher:
#     def __init__(self, constraints):
#         self.activity = {}
#         # Exercice: calculez l'activite de chaque variable
#
#     def select_variable(self, unassigned_vars):
#         # Exercice: retournez la variable avec l'activite max
#         pass

# Votre code ici
print("Exercice a completer")
Exercice a completer

Exemple guide 3 : Ordonnancement de graphe de tâches avec stratégies

Un graphe de tâches (task graph) est un ensemble de tâches avec des precedences : certaines tâches doivent etre terminees avant que d autres puissent commencer.

Soit le graphe suivant avec 8 tâches et 3 processeurs :

T0 (duree=3) ---> T2 (duree=4) ---> T5 (duree=2)
                  T2 -----------> T6 (duree=5)
T1 (duree=2) ---> T3 (duree=3) ---> T5
                  T3 -----------> T7 (duree=1)
     T0 ---------> T4 (duree=6) ---> T6
                                    T7

Objectif : assigner chaque tâche a un processeur et un creneau horaire (makespan minimum) en respectant les precedences et la capacite (1 tâche/processeur/creneau).

Utilisez le template ci-dessous avec le solveur CP-SAT et comparez les stratégies.

# Exemple resolu : Ordonnancement de graphe de taches avec CP-SAT


def solve_task_graph(strategies=None):
    # Definition du graphe de taches
    tasks = {
        "T0": {"duration": 3, "successors": ["T2", "T4"]},
        "T1": {"duration": 2, "successors": ["T3"]},
        "T2": {"duration": 4, "successors": ["T5", "T6"]},
        "T3": {"duration": 3, "successors": ["T5", "T7"]},
        "T4": {"duration": 6, "successors": ["T6"]},
        "T5": {"duration": 2, "successors": []},
        "T6": {"duration": 5, "successors": []},
        "T7": {"duration": 1, "successors": []},
    }
    n_proc = 3
    horizon = sum(t["duration"] for t in tasks.values())

    model = cp_model.CpModel()

    starts, ends, procs, intervals = {}, {}, {}, {}
    for name, info in tasks.items():
        starts[name] = model.new_int_var(0, horizon, f"start_{name}")
        ends[name] = model.new_int_var(0, horizon, f"end_{name}")
        procs[name] = model.new_int_var(0, n_proc - 1, f"proc_{name}")
        intervals[name] = model.new_interval_var(
            starts[name], info["duration"], ends[name], f"interval_{name}"
        )

    for name, info in tasks.items():
        for succ in info["successors"]:
            model.add(ends[name] <= starts[succ])

    for p in range(n_proc):
        proc_intervals = []
        for name in tasks:
            is_on_p = model.new_bool_var(f"{name}_on_{p}")
            model.add(procs[name] == p).only_enforce_if(is_on_p)
            model.add(procs[name] != p).only_enforce_if(~is_on_p)
            optional = model.new_optional_interval_var(
                starts[name], tasks[name]["duration"], ends[name],
                is_on_p, f"opt_{name}_{p}"
            )
            proc_intervals.append(optional)
        model.add_no_overlap(proc_intervals)

    makespan = model.new_int_var(0, horizon, "makespan")
    for name in tasks:
        model.add(ends[name] <= makespan)
    model.minimize(makespan)

    solver = cp_model.CpSolver()
    # Determinisme du banc (#19036) : graine fixee + un seul worker,
    # sinon le portfolio multi-thread de CP-SAT varie le compte de branches
    # d'une execution a l'autre (54 -> 147 -> 150 mesures sur cette cellule).
    # Les strategies explicites ci-dessus restent appliquees en priorite.
    solver.parameters.random_seed = 0
    solver.parameters.num_search_workers = 1
    if strategies:
        for key, val in strategies:
            setattr(solver.parameters, key, val)

    status = solver.solve(model)
    if status in (cp_model.OPTIMAL, cp_model.FEASIBLE):
        schedule = {}
        for name in tasks:
            schedule[name] = {
                "start": solver.value(starts[name]),
                "end": solver.value(ends[name]),
                "proc": solver.value(procs[name]),
                "duration": tasks[name]["duration"],
            }
        return solver.value(makespan), schedule, {
            "status": solver.status_name(status),
            "time": solver.wall_time,
            "branches": solver.num_branches,
        }
    return None


# Comparaison de strategies
strategies_to_test = [
    ("Defaut", None),
    ("Linearisation", [("linearization_level", 2)]),
    ("Plus de travail", [("max_deterministic_time", 5.0)]),
]

print("Strategie" + " " * 20 + "Makespan  Temps(s)  Branches")
print("-" * 65)
for name, params in strategies_to_test:
    result = solve_task_graph(params)
    if result:
        ms, sched, stats = result
        print(f"{name:28s}  {ms:5d}     {stats['time']:.4f}    {stats['branches']:>8d}")
    else:
        print(f"{name:28s}  PAS DE SOLUTION")
Strategie                    Makespan  Temps(s)  Branches
-----------------------------------------------------------------
Defaut                           14     0.0064         160
Linearisation                    14     0.0117         128
Plus de travail                  14     0.0048         160

Exercice 3b : Portfolio avec 5 stratégies et timeout adaptatif

Enonce : Etendez le portfolio dynamique pour gerer 5 stratégies au lieu de 3, avec un mécanisme de timeout adaptatif qui double le temps alloue a chaque stratégie après chaque echec.

Stratégies suggerees : 1. Recherche naive (no heuristic) 2. Fail-first (smallest domain) 3. Dom/Wdeg (weighted degree) 4. Random (with restarts) 5. Activity-based

Consignes : 1. Inspirez-vous de la comparaison de stratégies de l’exemple ci-dessus (solve_task_graph) 2. Ajoutez un paramètre base_timeout qui double après chaque echec 3. Mesurez et affichez le temps passe dans chaque stratégie

# Exercice 3b : Portfolio avec 5 strategies et timeout adaptatif

# Exercice: creez une classe ExtendedPortfolio avec 5 strategies
# Indice : le timeout adaptatif double a chaque echec
# base_timeout * (2 ** num_failures)

# class ExtendedPortfolio:
#     def __init__(self, strategies, base_timeout=5.0):
#         ...
#
#     def solve(self, problem, max_total_time=60):
#         # Exercice: boucle avec timeout adaptatif
#         pass

# Votre code ici
print("Exercice a completer")
Exercice a completer

Exemple guide 4 : Integration LLM

Connectez un vrai LLM (via API OpenAI ou Anthropic) pour generer des modèles CSP a partir de descriptions textuelles. Le pipeline demande : 1. description en langage naturel -> prompt pour le LLM, 2. reponse JSON du LLM -> parser de l’Exercice 1b, 3. resolution avec CP-SAT, 4. formatage de la solution en langage naturel.

# Exemple resolu : Pipeline LLM vers CSP

import json as _json


class MockLLMClient:
    """Client LLM factice : repond avec un JSON pre-enregistre selon le prompt.

    Remplacez-le par un vrai client OpenAI / Anthropic pour une integration reelle :
        response = client.messages.create(model="claude-opus-4-7",
                                          messages=[{"role": "user", "content": prompt}])
        return response.content[0].text
    """

    def __init__(self):
        self._canned = {
            "coloration": {
                "variables": [
                    {"name": "A", "min": 0, "max": 2},
                    {"name": "B", "min": 0, "max": 2},
                    {"name": "C", "min": 0, "max": 2},
                ],
                "constraints": [
                    {"type": "different", "vars": ["A", "B"]},
                    {"type": "different", "vars": ["B", "C"]},
                ],
                "objective": {"type": "minimize", "expr": "A + B + C"},
            },
            "budget": {
                "variables": [
                    {"name": "a", "min": 0, "max": 100},
                    {"name": "b", "min": 0, "max": 100},
                ],
                "constraints": [
                    {"type": "sum_le", "vars": ["a", "b"], "value": 50},
                ],
                "objective": {"type": "maximize", "expr": "a + b"},
            },
        }

    def query(self, prompt: str) -> str:
        lower = prompt.lower()
        for key, spec in self._canned.items():
            if key in lower:
                return _json.dumps(spec)
        # Defaut : petit probleme vide
        return _json.dumps({"variables": [], "constraints": []})


def _build_prompt(description: str) -> str:
    return (
        "Tu es un expert en programmation par contraintes. "
        "Convertis la description suivante en specification JSON compatible "
        "avec build_model_from_llm_spec (variables, constraints, objective). "
        "Reponds UNIQUEMENT avec du JSON.\n\n"
        f"Description : {description}"
    )


def describe_to_model(description: str, llm_client=None):
    """Description NL -> (model CP-SAT, vars) via LLM + parser Exo 1."""
    client = llm_client or MockLLMClient()
    prompt = _build_prompt(description)
    try:
        raw = client.query(prompt)
    except Exception as exc:
        raise RuntimeError(f"Appel LLM echoue : {exc}")
    spec = _json.loads(raw)
    return build_model_from_llm_spec(spec)


def solve_and_explain(description: str, llm_client=None) -> str:
    """Orchestration complete : NL -> modele -> resolution -> reponse NL."""
    model, variables = describe_to_model(description, llm_client)
    solver = cp_model.CpSolver()
    status = solver.Solve(model)

    if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
        return f"Aucune solution trouvee (statut = {solver.StatusName(status)})."

    lines = [f"Statut : {solver.StatusName(status)}"]
    for name, var in variables.items():
        lines.append(f"  {name} = {solver.Value(var)}")
    try:
        lines.append(f"Objectif : {solver.ObjectiveValue()}")
    except Exception:
        pass
    return "\n".join(lines)


# Tests
print("--- Demo 1 : coloration ---")
print(solve_and_explain("Coloration d'un petit graphe a 3 noeuds"))
print("\n--- Demo 2 : budget ---")
print(solve_and_explain("Repartition de budget sous contrainte totale"))
--- Demo 1 : coloration ---
Statut : OPTIMAL
  A = 0
  B = 1
  C = 0
Objectif : 1.0

--- Demo 2 : budget ---
Statut : OPTIMAL
  a = 0
  b = 50
Objectif : 50.0

Exercice 4b : Pipeline LLM pour un problème de coloration de graphe

Enonce : Adaptez le pipeline LLM pour un problème de coloration de graphe. Le LLM recoit une description textuelle du graphe et doit produire un JSON avec les variables (noeuds), les domaines (couleurs) et les contraintes (aretes).

Description d’entree :

Coloriez les 6 regions suivantes avec 3 couleurs (R, V, B)
de sorte que deux regions adjacentes n'aient pas la même couleur.
Adjacences : A-B, A-C, B-C, B-D, C-E, D-E, D-F, E-F

Consignes : 1. Inspirez-vous du mock LLM de l’exemple ci-dessus 2. Simulez la reponse du LLM avec un dictionnaire pre-formatte 3. Parsez la reponse et construisez le modèle CP-SAT 4. Resolvez et affichez la coloration obtenue

# Exercice 4b : Pipeline LLM pour coloration de graphe

# Exercice: simulez la reponse d'un LLM pour un probleme de coloration
# Le JSON doit contenir : variables (noeuds), domaines (couleurs),
# et contraintes (paires de noeuds adjacents)

# mock_llm_response = {
#     "variables": ["A", "B", "C", "D", "E", "F"],
#     "domains": {"A": ["R","V","B"], ...},
#     "constraints": [{"type": "neq", "vars": ["A","B"]}, ...]
# }

# Exercice: parsez et resolvez avec CP-SAT
# Indice : utilisez des NewBoolVar ou NewIntVar pour les couleurs

# Votre code ici
print("Exercice a completer")
Exercice a completer

Références

  1. Lazy Clause Generation (2010): T. Feydy, P.J. Stuckey

  2. CP-SAT Primer (2023): Google OR-Tools Team

  3. Machine Learning for CP (2018): E. Balas et al.

  4. LLM + Reasoning (2023):_chain-of-thought papers

  5. Ohrimenko, A., Stuckey, P.J. & Codish, M. (2009). Propagation via Lazy Clause Generation. Constraints 14(3):357-391.

  6. Een, N. & Sorensson, N. (2006). Translating Pseudo-Boolean Constraints into SAT. Journal on Satisfiability, Boolean Modeling and Computation 2:1-26.

  7. Kotthoff, L. (2016). Algorithm Sélection for Combinatorial Search Problems: A Survey. AI Magazine 35(3):48-60.

  8. Xu, L., Hutter, F., Hoos, H.H. & Leyton-Brown, K. (2008). SATzilla: Portfolio-based Algorithm Sélection for SAT. Journal of Artificial Intelligence Research 32:565-606.


Conclusion

Ce notebook a présenté les approches hybrides qui combinent plusieurs techniques de résolution CSP.

Concepts clés

Concept Description
Hybridation Combiner plusieurs algorithmes/paradigmes
CP + SAT Constraint Programming + SAT solving
CP + LP Constraint Programming + Linear Programming
CP + Recherche locale Backtracking + hill climbing/simulated annealing
CP + ML Machine Learning pour guider la recherche

Types d’hybridation

Type Combinaison Avantages
CP-SAT Contraintes + SAT solver Vérification + propagation
CP-LP CP + relaxation continue Bornes + flexibilité
LS-in-CP Recherche locale dans CP Échappe optima locaux
CP-in-LS Propagation dans recherche locale Réduit espace de voisinage
Portfolio Plusieurs solveurs en parallèle Robustesse

Points clés à retenir

  1. L’hybridation exploite les forces de chaque approche
  2. CP-SAT (CP-SAT de Google OR-Tools) est très performante
  3. La relaxation LP fournit des bornes utiles pour l’optimisation
  4. La recherche locale peut compléter CP pour les problèmes très durs
  5. Les approches portfolio gagnent souvent les compétitions SAT/CSP

Voir aussi : - App-9-EdgeDetection.ipynb - Application hybride - CSP-7-Soft.ipynb pour les métaheuristiques

Retour au sommet