""" PLRS — Logic Engine HuggingFace Space entry point. Loads SAKT model weights from HF Hub (Clementio/PLRS). Bundles the plrs package inline (until PyPI release). """ import json import sys from pathlib import Path import numpy as np import streamlit as st import torch ROOT = Path(__file__).resolve().parent sys.path.insert(0, str(ROOT)) from plrs.curriculum.loader import load_dag from plrs.pipeline import PLRSPipeline # ── Page config ─────────────────────────────────────────────────────────────── st.set_page_config( page_title="PLRS · Logic Engine", page_icon="🧠", layout="wide", initial_sidebar_state="expanded", ) # ── Styling ─────────────────────────────────────────────────────────────────── st.markdown(""" """, unsafe_allow_html=True) # ── Model + pipeline loading ────────────────────────────────────────────────── @st.cache_resource(show_spinner="Loading curriculum & model from HuggingFace...") def load_pipelines(): from plrs.model.model_loader import load_model_from_hub device = torch.device("cuda" if torch.cuda.is_available() else "cpu") maps = ROOT / "data" / "knowledge_maps" # Load model (tries decay, vanilla, then base) model, model_type = load_model_from_hub(device=str(device)) pipelines = {} for domain, fname in [("math", "math_dag.json"), ("cs", "cs_dag.json")]: path = maps / fname if path.exists(): curriculum = load_dag(path) pipeline = PLRSPipeline(curriculum) if model: pipeline._model = model pipelines[domain] = pipeline return pipelines, model is not None, model_type @st.cache_data def load_skill_encoder(): import pandas as pd path = ROOT / "data" / "skill_encoder_v2.csv" if path.exists(): return pd.read_csv(path) return None pipelines, has_model, model_type = load_pipelines() skill_encoder = load_skill_encoder() # ── Sidebar ─────────────────────────────────────────────────────────────────── with st.sidebar: st.markdown("### 🧠 PLRS") st.markdown('

LOGIC ENGINE v0.2.0

', unsafe_allow_html=True) if has_model: st.markdown(f'

● {model_type} LOADED

', unsafe_allow_html=True) else: st.markdown('

● MANUAL MODE

', unsafe_allow_html=True) st.markdown("---") domain_label = st.selectbox("Curriculum", ["Nigerian SS Mathematics", "CS Fundamentals"]) domain_key = "math" if "Mathematics" in domain_label else "cs" pipeline = pipelines[domain_key] curriculum = pipeline.curriculum st.markdown("---") threshold = st.slider("Mastery threshold", 0.50, 0.90, 0.70, 0.05) soft_threshold = st.slider("Challenging threshold", 0.20, 0.65, 0.50, 0.05) top_n = st.slider("Top N recommendations", 3, 10, 5) pipeline.threshold = threshold pipeline.soft_threshold = soft_threshold pipeline.top_n = top_n st.markdown("---") st.markdown(f'

NODES: {curriculum.num_nodes}

', unsafe_allow_html=True) st.markdown(f'

EDGES: {curriculum.num_edges}

', unsafe_allow_html=True) st.markdown(f'

MODEL: {model_type}

', unsafe_allow_html=True) st.markdown(f'

VIOLATION RATE: 0.0%

', unsafe_allow_html=True) st.markdown("---") st.markdown('

github.com/clementina-tom/plrs

', unsafe_allow_html=True) # ── Header ──────────────────────────────────────────────────────────────────── st.markdown("""
Logic Engine Personalized Learning · Constraint-Aware · SAKT + DAG
""", unsafe_allow_html=True) # ── Tabs ────────────────────────────────────────────────────────────────────── tab1, tab2, tab3 = st.tabs(["RECOMMENDATIONS", "WHAT-IF SIMULATOR", "CURRICULUM MAP"]) ACTIVITY_TO_DOMAIN = { "math": { "oucontent": "algebraic_expressions", "forumng": "statistics_basic", "homepage": "whole_numbers", "subpage": "plane_shapes", "resource": "indices", "url": "number_bases", "ouwiki": "proportion_variation", "glossary": "algebraic_factorization", "quiz": "quadratic_equations", }, "cs": { "oucontent": "programming_concepts", "forumng": "ethics_technology", "homepage": "computer_basics", "subpage": "html_basics", "resource": "networking_fundamentals", "url": "internet_basics", "ouwiki": "cloud_basics", "glossary": "intro_databases", "quiz": "python_basics", }, } # ══════════════════════════════════════════════════════════════════════════════ # TAB 1 — RECOMMENDATIONS # ══════════════════════════════════════════════════════════════════════════════ with tab1: col_left, col_right = st.columns([1, 1.4], gap="large") with col_left: st.markdown('
Learner Profile
', unsafe_allow_html=True) mode = st.radio("Input mode", ["Manual sliders", "Simulate student"], horizontal=True, label_visibility="collapsed") mastery_scores = {} if mode == "Manual sliders": for node in curriculum.nodes: label = curriculum.label(node) level = curriculum.level(node) val = st.slider( f"{label}", 0.0, 1.0, 0.0, 0.05, key=f"mastery_{node}", help=f"Level: {level}" ) mastery_scores[node] = val else: seq_len = st.slider("Sequence length", 10, 200, 50) seed = st.number_input("Student seed", 1, 9999, 42) np.random.seed(int(seed)) activity_types = list(ACTIVITY_TO_DOMAIN[domain_key].keys()) activity_probs = [0.38, 0.20, 0.15, 0.10, 0.06, 0.04, 0.03, 0.02, 0.02] mapping = ACTIVITY_TO_DOMAIN[domain_key] # Use skill_encoder to simulate skills that actually exist in the mapping if skill_encoder is not None: available_skills = skill_encoder["skill_id"].tolist() sim_skills = np.random.choice(available_skills, seq_len).tolist() else: n_skills = 5736 sim_skills = np.random.randint(0, n_skills, seq_len).tolist() sim_corrects = np.random.randint(0, 2, seq_len).tolist() topic_scores: dict = {} # Map simulated skills back to topics using the CSV work you did for skill_id in sim_skills: # If we have the encoder, find the activity type if skill_encoder is not None: row = skill_encoder[skill_encoder["skill_id"] == skill_id] if not row.empty: act = row["activity_type"].values[0] topic_id = mapping.get(act) if topic_id and topic_id in curriculum.nodes: # Generate a mastery signal based on frequency/success score = topic_scores.get(topic_id, 0.1) # Every time they see this topic, increase mastery slightly topic_scores[topic_id] = min(1.0, score + np.random.random() * 0.2) else: # Fallback to the old simple mapping if CSV is missing act_idx = skill_id % 100 cumulative = 0 thresholds = [int(p * 100) for p in activity_probs] thresholds[-1] += 100 - sum(thresholds) act = activity_types[-1] for a, thresh in zip(activity_types, thresholds): cumulative += thresh if act_idx < cumulative: act = a break topic_id = mapping.get(act) if topic_id and topic_id in curriculum.nodes: topic_scores[topic_id] = 0.5 + np.random.random() * 0.4 mastery_scores = {n: 0.0 for n in curriculum.nodes} mastery_scores.update(topic_scores) st.success(f"Simulated {seq_len} interactions → {len(topic_scores)} topics mapped") if topic_scores: st.markdown('
Mapped Mastery Signal
', unsafe_allow_html=True) for tid, score in sorted(topic_scores.items(), key=lambda x: -x[1]): pct = int(score * 100) color = "#22c55e" if score >= threshold else "#f59e0b" if score >= soft_threshold else "#ef4444" st.markdown(f"""
{curriculum.label(tid)} {pct}%
""", unsafe_allow_html=True) run = st.button("⚡ Generate Recommendations", type="primary", use_container_width=True) with col_right: if run or mode == "Simulate student": # Enable cascading for simulation to ensure prerequisites are also "mastered" is_sim = (mode == "Simulate student") results = pipeline.recommend_from_mastery(mastery_scores, cascade=is_sim) summary = results["mastery_summary"] stats = results["stats"] mastery_pct = int(summary["mastery_rate"] * 100) vrate_pct = int(stats["prerequisite_violation_rate"] * 100) st.markdown(f"""
Mastered
{summary['mastered']}/{summary['total_topics']}
{mastery_pct}% rate
Approved
{stats['approved_count']}
ready to learn
Challenging
{stats['challenging_count']}
partial prereqs
Violation rate
{vrate_pct}%
blocked topics
""", unsafe_allow_html=True) if results["approved"]: st.markdown('
✅ Approved Recommendations
', unsafe_allow_html=True) for i, rec in enumerate(results["approved"]): score_pct = int(rec["score"] * 100) st.markdown(f"""
{i+1}. {rec['topic_label']}
score: {rec['score']:.3f}  ·  mastery: {int(rec['mastery']*100)}%  ·  unlocks: {rec['downstream_count']}
{rec['reasoning']}
""", unsafe_allow_html=True) else: st.info("No approved topics — lower the mastery threshold or set some mastery levels.") if results["challenging"]: st.markdown('
⚠️ Challenging
', unsafe_allow_html=True) for rec in results["challenging"]: score_pct = int(rec["score"] * 100) unmet = ", ".join(rec["unmet_prerequisites"]) or "—" st.markdown(f"""
{rec['topic_label']}
score: {rec['score']:.3f}  ·  strengthen: {unmet}
{rec['reasoning']}
""", unsafe_allow_html=True) if results["vetoed"]: with st.expander(f"❌ Vetoed topics ({stats['vetoed_count']} total — prerequisite check failed)"): for rec in results["vetoed"]: unmet = ", ".join(rec["unmet_prerequisites"]) or "—" st.markdown(f"""
{rec['topic_label']}
blocked by: {unmet}
""", unsafe_allow_html=True) else: st.markdown("""
SET MASTERY LEVELS · THEN GENERATE
""", unsafe_allow_html=True) # ══════════════════════════════════════════════════════════════════════════════ # TAB 2 — WHAT-IF SIMULATOR # ══════════════════════════════════════════════════════════════════════════════ with tab2: st.markdown('
Prerequisite Impact Simulator
', unsafe_allow_html=True) st.markdown('

Select any topic to see what it unlocks and what currently blocks it.

', unsafe_allow_html=True) node_options = {curriculum.label(n): n for n in curriculum.nodes} selected_label = st.selectbox("Select topic", list(node_options.keys())) selected_id = node_options[selected_label] wi = pipeline.what_if(selected_id) col_a, col_b = st.columns(2, gap="large") with col_a: st.markdown('
🔓 What This Unlocks
', unsafe_allow_html=True) if wi["direct_unlocks"]: st.markdown("**Directly unlocks:**") st.markdown("".join(f'{u["label"]}' for u in wi["direct_unlocks"]), unsafe_allow_html=True) else: st.markdown('Leaf node — no further topics.', unsafe_allow_html=True) if wi["all_unlocks"]: st.markdown(f"**All downstream ({wi['total_unlocked']}):**") st.markdown("".join(f'{u["label"]}' for u in wi["all_unlocks"]), unsafe_allow_html=True) st.markdown(f"""
Total Unlocked
{wi['total_unlocked']}
""", unsafe_allow_html=True) with col_b: st.markdown('
🔒 What Blocks This
', unsafe_allow_html=True) if wi["blocked_by"]: st.markdown("**Prerequisites:**") st.markdown("".join(f'{b["label"]}' for b in wi["blocked_by"]), unsafe_allow_html=True) else: st.markdown('Root topic — no prerequisites.', unsafe_allow_html=True) # ══════════════════════════════════════════════════════════════════════════════ # TAB 3 — CURRICULUM MAP # ══════════════════════════════════════════════════════════════════════════════ with tab3: st.markdown('
Curriculum Knowledge Graph
', unsafe_allow_html=True) col_info, col_table = st.columns([1, 2], gap="large") with col_info: roots = [n for n in curriculum.nodes if not curriculum.prerequisites(n)] leaves = [n for n in curriculum.nodes if not curriculum.successors(n)] st.markdown(f"""
Domain
{curriculum.domain}
Topics
{curriculum.num_nodes}
Prerequisite Edges
{curriculum.num_edges}
""", unsafe_allow_html=True) st.markdown('
Root Topics
', unsafe_allow_html=True) st.markdown("".join(f'{curriculum.label(r)}' for r in roots), unsafe_allow_html=True) st.markdown('
Leaf Topics
', unsafe_allow_html=True) st.markdown("".join(f'{curriculum.label(l)}' for l in leaves), unsafe_allow_html=True) with col_table: import pandas as pd st.markdown('
All Topics
', unsafe_allow_html=True) rows = [] for node in curriculum.nodes: rows.append({ "Topic": curriculum.label(node), "Level": curriculum.level(node), "Prerequisites": len(curriculum.prerequisites(node)), "Unlocks (direct)": len(curriculum.successors(node)), "Total Downstream": len(curriculum.descendants(node)), }) df = pd.DataFrame(rows).sort_values("Total Downstream", ascending=False) st.dataframe(df, use_container_width=True, height=480, hide_index=True)