sperner 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sperner/__init__.py +78 -0
- sperner/adaptive_solver.py +129 -0
- sperner/agentic_judge.py +53 -0
- sperner/analytics.py +47 -0
- sperner/human_ui.py +224 -0
- sperner/industrial.py +112 -0
- sperner/moe_router.py +101 -0
- sperner/ndim_solver.py +372 -0
- sperner/plotting.py +143 -0
- sperner/py.typed +0 -0
- sperner/rlhf_steering_demo.py +212 -0
- sperner/solver.py +254 -0
- sperner/sperner_trainer.py +159 -0
- sperner/surrogate_solver.py +326 -0
- sperner-0.1.0.dist-info/METADATA +219 -0
- sperner-0.1.0.dist-info/RECORD +18 -0
- sperner-0.1.0.dist-info/WHEEL +4 -0
- sperner-0.1.0.dist-info/licenses/LICENSE +22 -0
sperner/__init__.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Equilib — Gradient-free multi-objective alignment via Sperner's Lemma.
|
|
3
|
+
|
|
4
|
+
Provides N-dimensional topological solvers, surrogate active-learning solvers,
|
|
5
|
+
PEFT/LoRA integration, MoE routing, and human-in-the-loop alignment tools.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
from typing import Callable, Optional, Union
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import torch
|
|
14
|
+
|
|
15
|
+
from .ndim_solver import NDimEquilibSolver, SpernerConvergenceError
|
|
16
|
+
from .sperner_trainer import SpernerTrainer
|
|
17
|
+
from .surrogate_solver import NDimSurrogateEquilibSolver, SurrogateEquilibSolver
|
|
18
|
+
from .solver import EquilibSolver
|
|
19
|
+
from .adaptive_solver import AdaptiveEquilibSolver
|
|
20
|
+
from .analytics import calculate_frustration_score
|
|
21
|
+
from .agentic_judge import AgenticEquilibriumJudge, auto_align_batch
|
|
22
|
+
from .industrial import AutoModelMerger
|
|
23
|
+
from .moe_router import TopologicalMoERouter
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def solve_equilibrium(
|
|
27
|
+
n_objs: int,
|
|
28
|
+
subdivision: int = 100,
|
|
29
|
+
oracle: Optional[Callable[[np.ndarray], int]] = None,
|
|
30
|
+
) -> Union[np.ndarray, NDimEquilibSolver]:
|
|
31
|
+
"""High-level utility to solve an equilibrium problem.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
n_objs: Number of objectives to balance (>= 2).
|
|
35
|
+
subdivision: Resolution of the search grid (>= 2).
|
|
36
|
+
oracle: A callable taking a weight vector (numpy array of shape ``(n_objs,)``)
|
|
37
|
+
and returning the index of the most dissatisfied objective.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
If *oracle* is provided, a numpy array of optimal weights.
|
|
41
|
+
Otherwise, an :class:`NDimEquilibSolver` instance for manual use.
|
|
42
|
+
|
|
43
|
+
Example::
|
|
44
|
+
|
|
45
|
+
>>> from sperner import solve_equilibrium
|
|
46
|
+
>>> weights = solve_equilibrium(3, subdivision=20,
|
|
47
|
+
... oracle=lambda w: int(np.argmax([0.4, 0.4, 0.2] - w)))
|
|
48
|
+
"""
|
|
49
|
+
solver = NDimEquilibSolver(n_objs=n_objs, subdivision=subdivision)
|
|
50
|
+
if oracle is not None:
|
|
51
|
+
|
|
52
|
+
def wrapped_oracle(weights_batch: torch.Tensor) -> torch.Tensor:
|
|
53
|
+
batch_size = weights_batch.shape[0]
|
|
54
|
+
labels = torch.zeros(batch_size, dtype=torch.long)
|
|
55
|
+
for i in range(batch_size):
|
|
56
|
+
labels[i] = oracle(weights_batch[i].cpu().numpy())
|
|
57
|
+
return labels
|
|
58
|
+
|
|
59
|
+
result = solver.solve(oracle_fn=wrapped_oracle, batch_size=1)
|
|
60
|
+
return result[0].cpu().numpy()
|
|
61
|
+
return solver
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
__all__ = [
|
|
65
|
+
"NDimEquilibSolver",
|
|
66
|
+
"NDimSurrogateEquilibSolver",
|
|
67
|
+
"SpernerTrainer",
|
|
68
|
+
"EquilibSolver",
|
|
69
|
+
"SurrogateEquilibSolver",
|
|
70
|
+
"AdaptiveEquilibSolver",
|
|
71
|
+
"AutoModelMerger",
|
|
72
|
+
"TopologicalMoERouter",
|
|
73
|
+
"AgenticEquilibriumJudge",
|
|
74
|
+
"auto_align_batch",
|
|
75
|
+
"calculate_frustration_score",
|
|
76
|
+
"SpernerConvergenceError",
|
|
77
|
+
"solve_equilibrium",
|
|
78
|
+
]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
from .solver import EquilibSolver
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AdaptiveEquilibSolver(EquilibSolver):
|
|
12
|
+
"""Iterative-refinement ("zoom") solver for high-precision 3-objective alignment.
|
|
13
|
+
|
|
14
|
+
Repeatedly solves on coarser grids and re-bases the search simplex onto the
|
|
15
|
+
panchromatic triangle found in the previous iteration, achieving exponential
|
|
16
|
+
precision improvement with linear cost.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
subdivision: Base grid resolution per zoom level.
|
|
20
|
+
max_depth: Maximum number of zoom iterations.
|
|
21
|
+
precision: Target diameter for the final triangle (in weight space).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
subdivision: int = 10,
|
|
27
|
+
max_depth: int = 5,
|
|
28
|
+
precision: float = 1e-6,
|
|
29
|
+
) -> None:
|
|
30
|
+
super().__init__(subdivision)
|
|
31
|
+
self.max_depth = max_depth
|
|
32
|
+
self.precision = precision
|
|
33
|
+
self.basis = np.eye(3)
|
|
34
|
+
|
|
35
|
+
def weights_from_coords(self, x, y):
|
|
36
|
+
"""
|
|
37
|
+
Maps local grid coordinates (x, y) to Global Weights via the current Basis.
|
|
38
|
+
"""
|
|
39
|
+
# 1. Local Barycentric Coordinates (u, v, w)
|
|
40
|
+
u = x / self.n
|
|
41
|
+
v = y / self.n
|
|
42
|
+
w = (self.n - x - y) / self.n
|
|
43
|
+
|
|
44
|
+
local_weights = np.array([u, v, w])
|
|
45
|
+
|
|
46
|
+
# 2. Map to Global Simplex via Matrix Multiplication
|
|
47
|
+
return local_weights @ self.basis
|
|
48
|
+
|
|
49
|
+
def solve_adaptive(self):
|
|
50
|
+
"""
|
|
51
|
+
Runs the iterative 'Zoom' process.
|
|
52
|
+
"""
|
|
53
|
+
logger.info(
|
|
54
|
+
f"Starting Adaptive Sperner (Depth {self.max_depth}, Grid {self.n})..."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
final_tri = None
|
|
58
|
+
global_tri_weights = []
|
|
59
|
+
|
|
60
|
+
for depth in range(1, self.max_depth + 1):
|
|
61
|
+
logger.info(f"DEPTH {depth}: Zooming into sub-simplex...")
|
|
62
|
+
# Run the standard walk on the current basis
|
|
63
|
+
result_tri_coords, path = self.walk()
|
|
64
|
+
|
|
65
|
+
if not result_tri_coords:
|
|
66
|
+
logger.error("FAIL: Walk failed at this depth.")
|
|
67
|
+
break
|
|
68
|
+
|
|
69
|
+
# Extract the vertices of the result triangle in GLOBAL weights
|
|
70
|
+
# The result_tri_coords are integer tuples [(x1,y1), (x2,y2), (x3,y3)]
|
|
71
|
+
global_tri_weights = []
|
|
72
|
+
vertex_labels = []
|
|
73
|
+
|
|
74
|
+
for pt in result_tri_coords:
|
|
75
|
+
g_w = self.weights_from_coords(*pt)
|
|
76
|
+
label = self.oracle_label(*pt)
|
|
77
|
+
global_tri_weights.append(g_w)
|
|
78
|
+
vertex_labels.append(label)
|
|
79
|
+
|
|
80
|
+
# Visualization of current precision
|
|
81
|
+
d01 = np.linalg.norm(global_tri_weights[0] - global_tri_weights[1])
|
|
82
|
+
d12 = np.linalg.norm(global_tri_weights[1] - global_tri_weights[2])
|
|
83
|
+
d20 = np.linalg.norm(global_tri_weights[2] - global_tri_weights[0])
|
|
84
|
+
max_diam = max(d01, d12, d20)
|
|
85
|
+
|
|
86
|
+
# Calculate centroid
|
|
87
|
+
centroid = sum(global_tri_weights) / 3
|
|
88
|
+
logger.info(
|
|
89
|
+
f"RESULT Depth {depth}: Centroid {np.round(centroid, 5)} | Precision (Diam): {max_diam:.6f}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if max_diam < self.precision:
|
|
93
|
+
logger.info(
|
|
94
|
+
f"DONE: Precision target {self.precision} reached.")
|
|
95
|
+
break
|
|
96
|
+
|
|
97
|
+
# PREPARE NEXT DEPTH: "Zoom" into this triangle
|
|
98
|
+
|
|
99
|
+
# Check if we have a panchromatic triangle (labels {0, 1, 2})
|
|
100
|
+
if set(vertex_labels) != {0, 1, 2}:
|
|
101
|
+
logger.warning(
|
|
102
|
+
f"WARN: Triangle at depth {depth} is not panchromatic: {vertex_labels}. Zooming might fail."
|
|
103
|
+
)
|
|
104
|
+
break
|
|
105
|
+
|
|
106
|
+
new_basis = np.zeros((3, 3))
|
|
107
|
+
|
|
108
|
+
for w, l in zip(global_tri_weights, vertex_labels):
|
|
109
|
+
new_basis[l] = w
|
|
110
|
+
|
|
111
|
+
self.basis = new_basis
|
|
112
|
+
self.vertices = {}
|
|
113
|
+
|
|
114
|
+
final_tri = global_tri_weights
|
|
115
|
+
|
|
116
|
+
if final_tri:
|
|
117
|
+
logger.info(
|
|
118
|
+
f"COMPLETE: Final High-Precision Equilibrium: {np.round(sum(final_tri)/3, 7)}"
|
|
119
|
+
)
|
|
120
|
+
return final_tri
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
# Run Adaptive Solver
|
|
125
|
+
# Start with a coarse grid (n=10) but zoom in 10 times.
|
|
126
|
+
solver = AdaptiveEquilibSolver(subdivision=10,
|
|
127
|
+
max_depth=10,
|
|
128
|
+
precision=1e-7)
|
|
129
|
+
solver.solve_adaptive()
|
sperner/agentic_judge.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import torch
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AgenticEquilibriumJudge:
|
|
6
|
+
"""Automated alignment judge that provides oracle labels without a human.
|
|
7
|
+
|
|
8
|
+
Uses a simulated capability surface to identify the weakest objective
|
|
9
|
+
for any given weight mix. In production, replace the scoring logic with
|
|
10
|
+
a distilled reward model.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
metrics: Human-readable names for each objective dimension.
|
|
14
|
+
device: Torch device for tensor operations.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, metrics: List[str], device: str = "cpu") -> None:
|
|
18
|
+
self.metrics = metrics
|
|
19
|
+
self.device = device
|
|
20
|
+
|
|
21
|
+
def get_labels(self, weights: torch.Tensor) -> torch.Tensor:
|
|
22
|
+
"""Return the index of the weakest objective for each row.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
weights: Tensor of shape ``(batch, n_objs)`` with non-negative weights.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Long tensor of shape ``(batch,)`` with label indices.
|
|
29
|
+
"""
|
|
30
|
+
batch_size = weights.shape[0]
|
|
31
|
+
n_objs = weights.shape[1]
|
|
32
|
+
|
|
33
|
+
# Simulated Capability Surface (Realistic Non-Linear Trade-offs)
|
|
34
|
+
# In a real system, this triggers actual inference.
|
|
35
|
+
with torch.no_grad():
|
|
36
|
+
# Objectives are satisfied based on weights but penalize each other (Alignment Tax)
|
|
37
|
+
scores = weights * 0.9 - 0.1 * torch.sum(
|
|
38
|
+
weights**2, dim=1, keepdim=True)
|
|
39
|
+
|
|
40
|
+
# The label is the index of the score that is furthest from perfection (1.0)
|
|
41
|
+
gaps = 1.0 - scores
|
|
42
|
+
return torch.argmax(gaps, dim=1)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def auto_align_batch(n_objs: int, batch_size: int = 128, device: str = "cpu"):
|
|
46
|
+
"""Plug-and-play batch alignment."""
|
|
47
|
+
from .ndim_solver import NDimEquilibSolver
|
|
48
|
+
|
|
49
|
+
judge = AgenticEquilibriumJudge(
|
|
50
|
+
metrics=[f"cap_{i}" for i in range(n_objs)], device=device)
|
|
51
|
+
solver = NDimEquilibSolver(n_objs=n_objs, device=device)
|
|
52
|
+
|
|
53
|
+
return solver.solve(oracle_fn=judge.get_labels, batch_size=batch_size)
|
sperner/analytics.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Alignment diagnostics and walk analytics."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Sequence, Union
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def calculate_frustration_score(
|
|
9
|
+
path_vertices: Sequence[Union[List[float], np.ndarray]], ) -> float:
|
|
10
|
+
"""Measure topological frustration of a Sperner walk path.
|
|
11
|
+
|
|
12
|
+
The frustration score is the ratio of total path length to net displacement.
|
|
13
|
+
|
|
14
|
+
* ~1.0 — direct convergence, minimal conflict.
|
|
15
|
+
* 1.5–3.0 — moderate trade-off complexity.
|
|
16
|
+
* >3.0 — high frustration; objectives are strongly conflicting.
|
|
17
|
+
* 999.0 — loop detected (zero net displacement).
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
path_vertices: Sequence of coordinate arrays visited by the solver.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Frustration score (float). Returns 1.0 for paths shorter than 2 steps.
|
|
24
|
+
"""
|
|
25
|
+
if not path_vertices or len(path_vertices) < 2:
|
|
26
|
+
return 1.0
|
|
27
|
+
|
|
28
|
+
path = np.array(path_vertices)
|
|
29
|
+
|
|
30
|
+
# 1. Calculate total distance walked (sum of Euclidean steps)
|
|
31
|
+
diffs = path[1:] - path[:-1]
|
|
32
|
+
distances = np.linalg.norm(diffs, axis=1)
|
|
33
|
+
total_dist = np.sum(distances)
|
|
34
|
+
|
|
35
|
+
# 2. Calculate displacement (Start to Finish)
|
|
36
|
+
start = path[0]
|
|
37
|
+
end = path[-1]
|
|
38
|
+
displacement = np.linalg.norm(end - start)
|
|
39
|
+
|
|
40
|
+
# Avoid division by zero
|
|
41
|
+
if displacement < 1e-9:
|
|
42
|
+
return 999.0 # Loop detected or returned to start
|
|
43
|
+
|
|
44
|
+
# Ratio: How much did we wander?
|
|
45
|
+
frustration_index = total_dist / displacement
|
|
46
|
+
|
|
47
|
+
return float(frustration_index)
|
sperner/human_ui.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import streamlit as st
|
|
2
|
+
import numpy as np
|
|
3
|
+
import requests
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import torch
|
|
7
|
+
from sperner.sperner_trainer import SpernerTrainer
|
|
8
|
+
from sperner.analytics import calculate_frustration_score
|
|
9
|
+
|
|
10
|
+
DEFAULT_OBJECTIVES = ["Safety", "Helpfulness", "Creativity"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main():
|
|
14
|
+
# Set up page
|
|
15
|
+
st.set_page_config(page_title="Sperner: Live Manifold Alignment",
|
|
16
|
+
layout="wide",
|
|
17
|
+
page_icon="🧬")
|
|
18
|
+
|
|
19
|
+
# Configuration Sidebar
|
|
20
|
+
with st.sidebar:
|
|
21
|
+
st.header("⚙️ Local LLM Config")
|
|
22
|
+
st.info("Compatible with OpenAI-style APIs (LM Studio, Ollama, vLLM)")
|
|
23
|
+
llm_url = st.text_input(
|
|
24
|
+
"Server URL", value="http://127.0.0.1:1234/v1/chat/completions")
|
|
25
|
+
model_name = st.text_input("Model Name", value="local-model")
|
|
26
|
+
|
|
27
|
+
default_prompt = (
|
|
28
|
+
"Write a short, highly creative story about a robot discovering a garden. "
|
|
29
|
+
"The story must be engaging but strictly avoid any mention of technology or electricity."
|
|
30
|
+
)
|
|
31
|
+
test_prompt = st.text_area("Test Prompt", value=default_prompt)
|
|
32
|
+
|
|
33
|
+
st.divider()
|
|
34
|
+
st.header("🎯 Objectives")
|
|
35
|
+
n_objs = st.number_input(
|
|
36
|
+
"Number of objectives",
|
|
37
|
+
min_value=2,
|
|
38
|
+
max_value=10,
|
|
39
|
+
value=len(DEFAULT_OBJECTIVES),
|
|
40
|
+
step=1,
|
|
41
|
+
)
|
|
42
|
+
obj_names = []
|
|
43
|
+
for i in range(int(n_objs)):
|
|
44
|
+
default = DEFAULT_OBJECTIVES[i] if i < len(
|
|
45
|
+
DEFAULT_OBJECTIVES) else f"Objective {i+1}"
|
|
46
|
+
obj_names.append(
|
|
47
|
+
st.text_input(f"Objective {i+1}",
|
|
48
|
+
value=default,
|
|
49
|
+
key=f"obj_{i}"))
|
|
50
|
+
st.info(
|
|
51
|
+
f"The solver will find the Nash Equilibrium between {len(obj_names)} goals."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Session State Initialization
|
|
55
|
+
if "solver_gen" not in st.session_state:
|
|
56
|
+
st.session_state.solver_gen = None
|
|
57
|
+
st.session_state.step = 0
|
|
58
|
+
st.session_state.history = []
|
|
59
|
+
st.session_state.current_weights = None
|
|
60
|
+
st.session_state.last_response = ""
|
|
61
|
+
st.session_state.finished = False
|
|
62
|
+
|
|
63
|
+
def call_local_llm(weights):
|
|
64
|
+
"""
|
|
65
|
+
Translates topological weights into a dynamic system prompt
|
|
66
|
+
and queries the local server with robust response parsing.
|
|
67
|
+
"""
|
|
68
|
+
w = weights.flatten()
|
|
69
|
+
priorities = ", ".join(f"{obj_names[i]} Weight: {w[i]:.2f}"
|
|
70
|
+
for i in range(len(obj_names)))
|
|
71
|
+
system_prompt = (
|
|
72
|
+
f"You are an AI with following priorities: {priorities}. "
|
|
73
|
+
f"Strictly follow these weights. Adjust your tone and content accordingly."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
payload = {
|
|
77
|
+
"model": model_name,
|
|
78
|
+
"system_prompt": system_prompt,
|
|
79
|
+
"input": test_prompt
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
response = requests.post(llm_url, json=payload, timeout=15)
|
|
84
|
+
if response.status_code == 200:
|
|
85
|
+
data = response.json()
|
|
86
|
+
|
|
87
|
+
# --- ROBUST PARSING STRATEGY ---
|
|
88
|
+
# 1. Standard OpenAI/LM-Studio Format
|
|
89
|
+
content = data.get("choices", [{}])[0].get("message",
|
|
90
|
+
{}).get("content")
|
|
91
|
+
if content: return content
|
|
92
|
+
|
|
93
|
+
# 2. Simple 'choices' with 'text' (Legacy Completions)
|
|
94
|
+
content = data.get("choices", [{}])[0].get("text")
|
|
95
|
+
if content: return content
|
|
96
|
+
|
|
97
|
+
# 3. Direct 'content' or 'response' keys (Ollama/Simple Wrappers)
|
|
98
|
+
content = data.get("content") or data.get(
|
|
99
|
+
"response") or data.get("output")
|
|
100
|
+
if content: return content
|
|
101
|
+
|
|
102
|
+
# 4. Fallback: Display the raw JSON so the user can debug
|
|
103
|
+
return f"⚠️ Unrecognized JSON structure. Raw response:\n{json.dumps(data, indent=2)}"
|
|
104
|
+
|
|
105
|
+
else:
|
|
106
|
+
return f"❌ Server Error {response.status_code}: {response.text}"
|
|
107
|
+
except Exception as e:
|
|
108
|
+
return f"❌ Connection Error: {str(e)}"
|
|
109
|
+
|
|
110
|
+
def start_alignment():
|
|
111
|
+
# We use the mock trainer just to get the generator logic
|
|
112
|
+
trainer = SpernerTrainer("mock", obj_names, [], mock=True)
|
|
113
|
+
st.session_state.solver_gen = trainer.train_generator(grid_size=10)
|
|
114
|
+
st.session_state.step = 1
|
|
115
|
+
st.session_state.history = []
|
|
116
|
+
st.session_state.finished = False
|
|
117
|
+
|
|
118
|
+
# Get first proposal
|
|
119
|
+
weights, _ = next(st.session_state.solver_gen)
|
|
120
|
+
st.session_state.current_weights = weights
|
|
121
|
+
st.session_state.last_response = call_local_llm(weights)
|
|
122
|
+
|
|
123
|
+
def submit_verdict(label_idx):
|
|
124
|
+
try:
|
|
125
|
+
# Feed the human label back to the solver
|
|
126
|
+
weights, _ = st.session_state.solver_gen.send(label_idx)
|
|
127
|
+
st.session_state.current_weights = weights
|
|
128
|
+
st.session_state.history.append(weights)
|
|
129
|
+
st.session_state.step += 1
|
|
130
|
+
# Get the new model response for the new weights
|
|
131
|
+
with st.spinner("Generating new response from local manifold..."):
|
|
132
|
+
st.session_state.last_response = call_local_llm(weights)
|
|
133
|
+
except StopIteration as e:
|
|
134
|
+
st.session_state.finished = True
|
|
135
|
+
st.session_state.final_result = e.value
|
|
136
|
+
|
|
137
|
+
# --- UI LAYOUT ---
|
|
138
|
+
st.title("🧬 Sperner: Live Manifold Alignment")
|
|
139
|
+
st.markdown("""
|
|
140
|
+
### Find the "Goldilocks Zone" of your Local LLM.
|
|
141
|
+
This tool uses a **Sperner Walk** to navigate the latent space of your model.
|
|
142
|
+
Choose the objective that is **currently failing** to steer the model toward equilibrium.
|
|
143
|
+
""")
|
|
144
|
+
|
|
145
|
+
if not st.session_state.solver_gen:
|
|
146
|
+
if st.button("🚀 Start Live Alignment Session",
|
|
147
|
+
use_container_width=True):
|
|
148
|
+
start_alignment()
|
|
149
|
+
st.rerun()
|
|
150
|
+
else:
|
|
151
|
+
if st.session_state.finished:
|
|
152
|
+
st.balloons()
|
|
153
|
+
st.success("✅ Nash Equilibrium Reached!")
|
|
154
|
+
if hasattr(st.session_state, 'final_result'
|
|
155
|
+
) and st.session_state.final_result is not None:
|
|
156
|
+
# result might be a torch.Tensor, numpy array or None
|
|
157
|
+
res = st.session_state.final_result
|
|
158
|
+
if isinstance(res, torch.Tensor):
|
|
159
|
+
res = res.cpu().numpy().flatten()
|
|
160
|
+
elif isinstance(res, (list, np.ndarray)):
|
|
161
|
+
res = np.array(res).flatten()
|
|
162
|
+
|
|
163
|
+
st.json(dict(zip(obj_names, res.tolist())))
|
|
164
|
+
|
|
165
|
+
if st.button("Restart"):
|
|
166
|
+
st.session_state.solver_gen = None
|
|
167
|
+
st.rerun()
|
|
168
|
+
else:
|
|
169
|
+
col1, col2 = st.columns([1, 2])
|
|
170
|
+
|
|
171
|
+
with col1:
|
|
172
|
+
st.subheader("📊 Current Manifold Weights")
|
|
173
|
+
if st.session_state.current_weights is not None:
|
|
174
|
+
for i, name in enumerate(obj_names):
|
|
175
|
+
if i < len(st.session_state.current_weights):
|
|
176
|
+
val = float(st.session_state.current_weights[i])
|
|
177
|
+
st.progress(val, text=f"{name}: {val*100:.1f}%")
|
|
178
|
+
|
|
179
|
+
st.divider()
|
|
180
|
+
st.metric("Walk Step", st.session_state.step)
|
|
181
|
+
if st.session_state.history:
|
|
182
|
+
f_score = calculate_frustration_score(
|
|
183
|
+
st.session_state.history)
|
|
184
|
+
st.write(f"Topology Frustration: `{f_score:.2f}`")
|
|
185
|
+
|
|
186
|
+
with col2:
|
|
187
|
+
st.subheader("🤖 Local LLM Response")
|
|
188
|
+
if st.session_state.current_weights is not None:
|
|
189
|
+
st.info(
|
|
190
|
+
f"Generated at weights: {np.round(st.session_state.current_weights, 2)}"
|
|
191
|
+
)
|
|
192
|
+
st.chat_message("assistant").write(
|
|
193
|
+
st.session_state.last_response)
|
|
194
|
+
|
|
195
|
+
st.divider()
|
|
196
|
+
st.subheader("👨⚖️ Your Verdict")
|
|
197
|
+
st.write(
|
|
198
|
+
"Which capability is **least satisfied** in this response?"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
n_cols = min(len(obj_names), 4)
|
|
202
|
+
for row_start in range(0, len(obj_names), n_cols):
|
|
203
|
+
v_cols = st.columns(min(n_cols,
|
|
204
|
+
len(obj_names) - row_start))
|
|
205
|
+
for j, name in enumerate(obj_names[row_start:row_start +
|
|
206
|
+
len(v_cols)]):
|
|
207
|
+
idx = row_start + j
|
|
208
|
+
if v_cols[j].button(
|
|
209
|
+
f"Needs more {name}",
|
|
210
|
+
key=f"btn_{idx}",
|
|
211
|
+
use_container_width=True,
|
|
212
|
+
type="primary" if idx == 0 else "secondary"):
|
|
213
|
+
submit_verdict(idx)
|
|
214
|
+
st.rerun()
|
|
215
|
+
|
|
216
|
+
if st.session_state.history:
|
|
217
|
+
st.divider()
|
|
218
|
+
st.subheader("📈 Alignment Path")
|
|
219
|
+
chart_data = np.array(st.session_state.history)
|
|
220
|
+
st.line_chart(chart_data)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
if __name__ == "__main__":
|
|
224
|
+
main()
|
sperner/industrial.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Callable, Dict, List
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import torch
|
|
6
|
+
|
|
7
|
+
from .ndim_solver import NDimEquilibSolver
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AutoModelMerger:
|
|
13
|
+
"""Production-grade model merger using topological alignment.
|
|
14
|
+
|
|
15
|
+
Finds the Nash equilibrium of conflicting model capabilities by mapping
|
|
16
|
+
each capability to a simplex dimension and running a Sperner walk.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
base_model_id: Identifier for the base model (e.g. HuggingFace repo id).
|
|
20
|
+
adapter_ids: List of adapter/capability identifiers to merge.
|
|
21
|
+
device: Torch device for the underlying solver.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
base_model_id: str,
|
|
27
|
+
adapter_ids: List[str],
|
|
28
|
+
device: str = "cpu",
|
|
29
|
+
) -> None:
|
|
30
|
+
self.base_model_id = base_model_id
|
|
31
|
+
self.adapter_ids = adapter_ids
|
|
32
|
+
self.capability_names = [aid.split('/')[-1] for aid in adapter_ids]
|
|
33
|
+
self.device = device
|
|
34
|
+
|
|
35
|
+
def find_optimal_mix(self,
|
|
36
|
+
evaluators: List[Callable],
|
|
37
|
+
precision: int = 50) -> Dict[str, float]:
|
|
38
|
+
"""
|
|
39
|
+
The 'Set and Forget' method for model alignment.
|
|
40
|
+
"""
|
|
41
|
+
logger.info(
|
|
42
|
+
f"Starting Industrial Alignment for: {self.capability_names}")
|
|
43
|
+
|
|
44
|
+
solver = NDimEquilibSolver(n_objs=len(self.adapter_ids),
|
|
45
|
+
subdivision=precision,
|
|
46
|
+
device=self.device)
|
|
47
|
+
|
|
48
|
+
def industrial_oracle(weights_batch: torch.Tensor) -> torch.Tensor:
|
|
49
|
+
"""
|
|
50
|
+
Vectorized oracle for the industrial merger.
|
|
51
|
+
"""
|
|
52
|
+
batch_size = weights_batch.shape[0]
|
|
53
|
+
labels = torch.zeros(batch_size,
|
|
54
|
+
dtype=torch.long,
|
|
55
|
+
device=self.device)
|
|
56
|
+
|
|
57
|
+
for i in range(batch_size):
|
|
58
|
+
weights = weights_batch[i].cpu().numpy()
|
|
59
|
+
scores = []
|
|
60
|
+
for ev in evaluators:
|
|
61
|
+
scores.append(ev(weights))
|
|
62
|
+
|
|
63
|
+
# We want to find the objective with the largest gap to the max score
|
|
64
|
+
# (The most dissatisfied capability)
|
|
65
|
+
scores_np = np.array(scores)
|
|
66
|
+
labels[i] = int(np.argmax(np.max(scores_np) - scores_np))
|
|
67
|
+
|
|
68
|
+
return labels
|
|
69
|
+
|
|
70
|
+
# High-performance synchronous solve
|
|
71
|
+
best_weights_tensor = solver.solve(oracle_fn=industrial_oracle,
|
|
72
|
+
batch_size=1)
|
|
73
|
+
|
|
74
|
+
# Convert back to dictionary
|
|
75
|
+
best_weights = best_weights_tensor[0].cpu().numpy()
|
|
76
|
+
return dict(zip(self.capability_names, best_weights))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def run_enterprise_demo():
|
|
80
|
+
print("\n" + "=" * 50)
|
|
81
|
+
print(" ENTERPRISE MODEL MERGING DEMO")
|
|
82
|
+
print("=" * 50)
|
|
83
|
+
|
|
84
|
+
merger = AutoModelMerger(
|
|
85
|
+
"meta-llama/Llama-3",
|
|
86
|
+
["adapters/speed", "adapters/accuracy", "adapters/safety"])
|
|
87
|
+
|
|
88
|
+
# Define simple business constraints
|
|
89
|
+
def speed_eval(w):
|
|
90
|
+
return float(w[0] * 0.9)
|
|
91
|
+
|
|
92
|
+
def accuracy_eval(w):
|
|
93
|
+
return float(w[1] * 0.95 - (w[0] * 0.1))
|
|
94
|
+
|
|
95
|
+
def safety_eval(w):
|
|
96
|
+
return float(w[2] * 1.0 - (w[1] * 0.2))
|
|
97
|
+
|
|
98
|
+
evaluators = [speed_eval, accuracy_eval, safety_eval]
|
|
99
|
+
|
|
100
|
+
print("[STEP 1] Calculating Nash Equilibrium for Capabilities...")
|
|
101
|
+
result = merger.find_optimal_mix(evaluators)
|
|
102
|
+
|
|
103
|
+
print("\n[STEP 2] Optimal Industrial Deployment Weights:")
|
|
104
|
+
for cap, weight in result.items():
|
|
105
|
+
print(f" --> {cap:10}: {weight*100:5.1f}%")
|
|
106
|
+
|
|
107
|
+
print("\n[RESULT] This mix guarantees maximum system stability.")
|
|
108
|
+
print("=" * 50)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
run_enterprise_demo()
|