From 1b508b911fce3795471d5b931e712dac65027262 Mon Sep 17 00:00:00 2001 From: Nathan Schneider Date: Sun, 21 Dec 2025 21:38:39 -0700 Subject: [PATCH] Added classifer analysis to bicorder ascii and web app --- analysis/INTEGRATION_GUIDE.md | 402 ++++++++++++++++ analysis/README.md | 46 ++ analysis/bicorder-classifier.d.ts | 98 ++++ analysis/bicorder-classifier.js | 335 +++++++++++++ analysis/bicorder_classifier.py | 366 ++++++++++++++ analysis/bicorder_model.json | 242 ++++++++++ analysis/export_model_for_js.py | 130 +++++ analysis/test-classifier.mjs | 37 ++ ascii_bicorder.py | 178 +++++++ bicorder-app/src/App.svelte | 269 +++++++++-- bicorder-app/src/bicorder-classifier.ts | 268 +++++++++++ .../src/components/AnalysisDisplay.svelte | 2 +- .../src/components/FormRecommendation.svelte | 450 ++++++++++++++++++ .../src/components/GradientSlider.svelte | 2 +- bicorder-app/src/vite-env.d.ts | 1 + bicorder-app/vite.config.ts | 8 +- bicorder.json | 10 + 17 files changed, 2795 insertions(+), 49 deletions(-) create mode 100644 analysis/INTEGRATION_GUIDE.md create mode 100644 analysis/bicorder-classifier.d.ts create mode 100644 analysis/bicorder-classifier.js create mode 100644 analysis/bicorder_classifier.py create mode 100644 analysis/bicorder_model.json create mode 100644 analysis/export_model_for_js.py create mode 100644 analysis/test-classifier.mjs create mode 100644 bicorder-app/src/bicorder-classifier.ts create mode 100644 bicorder-app/src/components/FormRecommendation.svelte diff --git a/analysis/INTEGRATION_GUIDE.md b/analysis/INTEGRATION_GUIDE.md new file mode 100644 index 0000000..92b72be --- /dev/null +++ b/analysis/INTEGRATION_GUIDE.md @@ -0,0 +1,402 @@ +# Bicorder Classifier Integration Guide + +## Overview + +This guide explains how to integrate the cluster classification system into the Bicorder web application to provide: + +1. **Real-time cluster prediction** as users fill out diagnostics +2. **Smart form selection** (short vs. long form based on classification confidence) +3. **Visual feedback** showing protocol family positioning + +## Design Philosophy + +**Version-based compatibility**: The model includes a `bicorder_version` field. The classifier checks that versions match. When bicorder.json structure changes: +1. Increment the version number in bicorder.json +2. Retrain the model with `python3 export_model_for_js.py` +3. The new model will have the updated version + +This ensures the web app and model stay in sync without complex backward compatibility. + +## Files + +- `bicorder_model.json` - Trained model parameters (5KB, embed in app) +- `bicorder-classifier.js` - JavaScript implementation +- `bicorder-classifier.d.ts` - TypeScript type definitions + +## Quick Start + +### 1. Copy Model File + +Copy `bicorder_model.json` to your web app's public/static assets: + +```bash +cp bicorder_model.json ../path/to/bicorder/public/ +``` + +### 2. Install Classifier + +Copy the JavaScript module to your source directory: + +```bash +cp bicorder-classifier.js ../path/to/bicorder/src/lib/ +cp bicorder-classifier.d.ts ../path/to/bicorder/src/lib/ +``` + +### 3. Basic Usage + +```javascript +import { loadClassifier } from './lib/bicorder-classifier.js'; + +// Load model once at app startup +const classifier = await loadClassifier('/bicorder_model.json'); + +// As user fills in diagnostic form +function onDimensionChange(dimensionName, value) { + const currentRatings = getCurrentFormValues(); // Your form state + + const result = classifier.predict(currentRatings); + + console.log(`Cluster: ${result.clusterName}`); + console.log(`Confidence: ${result.confidence}%`); + console.log(`Recommend: ${result.recommendedForm} form`); + + updateUI(result); +} +``` + +## Integration Patterns + +### Pattern 1: Progressive Classification Display + +Show classification results as the user fills out the form: + +```javascript +// React/Svelte component example +function DiagnosticForm() { + const [ratings, setRatings] = useState({}); + const [classification, setClassification] = useState(null); + + useEffect(() => { + if (Object.keys(ratings).length > 0) { + const result = classifier.predict(ratings); + setClassification(result); + } + }, [ratings]); + + return ( +
+ + + {classification && ( + + )} +
+ ); +} +``` + +### Pattern 2: Smart Form Selection + +Automatically switch between short and long forms: + +```javascript +function DiagnosticWizard() { + const [ratings, setRatings] = useState({}); + + function handleDimensionComplete(dimension, value) { + const newRatings = { ...ratings, [dimension]: value }; + setRatings(newRatings); + + // Check if we should switch forms + const result = classifier.predict(newRatings); + + if (result.recommendedForm === 'long' && currentForm === 'short') { + showFormSwitchPrompt( + 'Your protocol shows characteristics of both families. ' + + 'Would you like to use the detailed form for better classification?' + ); + } + } + + return
; +} +``` + +### Pattern 3: Short Form Optimization + +Only ask the 8 most discriminative dimensions for quick classification: + +```javascript +const shortFormDimensions = classifier.getKeyDimensions(); +// Returns: +// [ +// 'Design_elite_vs_vernacular', +// 'Entanglement_flocking_vs_swarming', +// 'Design_static_vs_malleable', +// 'Entanglement_obligatory_vs_voluntary', +// 'Entanglement_self-enforcing_vs_enforced', +// 'Design_explicit_vs_implicit', +// 'Entanglement_sovereign_vs_subsidiary', +// 'Design_technical_vs_social', +// ] + +function ShortForm() { + return ( +
+

Quick Classification (8 questions)

+ {shortFormDimensions.map(dim => ( + + ))} +
+ ); +} +``` + +### Pattern 4: Readiness Check + +Check if user has provided enough data for reliable classification: + +```javascript +function ClassificationStatus() { + const assessment = classifier.assessShortFormReadiness(ratings); + + if (!assessment.ready) { + return ( +
+

+ Need {assessment.keyDimensionsTotal - assessment.keyDimensionsProvided} more + key dimensions for reliable classification ({assessment.coverage}% complete) +

+
    + {assessment.missingKeyDimensions.slice(0, 3).map(dim => ( +
  • {formatDimensionName(dim)}
  • + ))} +
+
+ ); + } + + return ; +} +``` + +## UI Components + +### Classification Indicator + +Visual indicator showing cluster and confidence: + +```javascript +function ClassificationIndicator({ cluster, confidence, completeness }) { + const color = cluster === 1 ? '#2E86AB' : '#A23B72'; + + return ( +
+
+ {cluster === 1 ? 'Relational/Cultural' : 'Institutional/Bureaucratic'} +
+ +
+
+ {confidence}% confidence +
+ +
+ {completeness}% of dimensions provided +
+
+ ); +} +``` + +### Spectrum Visualization + +Show protocol position on the relational ↔ institutional spectrum: + +```javascript +function SpectrumVisualization({ ldaScore, distanceToBoundary }) { + // Scale LDA score to 0-100 for display + // Typical range is -4 to +4 + const position = ((ldaScore + 4) / 8) * 100; + const boundaryZone = distanceToBoundary < 0.5; + + return ( +
+
+
Relational/Cultural
+
Institutional/Bureaucratic
+ +
+ {boundaryZone && ( +
+ Boundary +
+ )} +
+
+
+
+ ); +} +``` + +## Form Selection Logic + +### When to Use Short Form + +- Initial protocol scan +- User wants quick classification +- Protocol clearly fits one family (confidence > 60%, distance > 0.5) + +### When to Use Long Form + +- Protocol near boundary (distance < 0.5) +- Low confidence (< 60%) +- User wants detailed analysis +- Research/documentation purposes + +### Recommended Flow + +``` +User starts diagnostic + ↓ +Show short form (8 key dimensions) + ↓ +Calculate partial classification + ↓ +Is confidence > 60% AND completeness > 75%? + ↓ YES ↓ NO +Show result Offer long form + "For better accuracy, + complete full diagnostic?" +``` + +## API Reference + +### `predict(ratings, options)` + +Main classification function. + +**Parameters:** +- `ratings`: Object mapping dimension names to values (1-9) +- `options.detailed`: Return detailed information (default: true) + +**Returns:** +```javascript +{ + cluster: 1 | 2, + clusterName: "Relational/Cultural" | "Institutional/Bureaucratic", + confidence: 0-100, + completeness: 0-100, + recommendedForm: "short" | "long", + // If detailed: true + ldaScore: number, + distanceToBoundary: number, + dimensionsProvided: number, + dimensionsTotal: 23, + keyDimensionsProvided: number, + keyDimensionsTotal: 8 +} +``` + +### `explainClassification(ratings)` + +Generate human-readable explanation. + +**Returns:** String with explanation text + +### `getKeyDimensions()` + +Get the 8 most discriminative dimensions for short form. + +**Returns:** Array of dimension names + +### `assessShortFormReadiness(ratings)` + +Check if enough key dimensions are provided. + +**Returns:** +```javascript +{ + ready: boolean, + keyDimensionsProvided: number, + keyDimensionsTotal: 8, + coverage: 0-100, + missingKeyDimensions: string[] +} +``` + +## Testing + +Test the classifier with example protocols: + +```javascript +import { BicorderClassifier } from './bicorder-classifier.js'; +import modelData from './bicorder_model.json'; + +const classifier = new BicorderClassifier(modelData); + +// Test 1: Clearly institutional +const institutional = { + 'Design_elite_vs_vernacular': 1, + 'Entanglement_obligatory_vs_voluntary': 1, + 'Entanglement_flocking_vs_swarming': 1, +}; +console.log(classifier.predict(institutional)); +// Expected: Cluster 2, high confidence + +// Test 2: Clearly relational +const relational = { + 'Design_elite_vs_vernacular': 9, + 'Entanglement_obligatory_vs_voluntary': 9, + 'Entanglement_flocking_vs_swarming': 9, +}; +console.log(classifier.predict(relational)); +// Expected: Cluster 1, high confidence + +// Test 3: Boundary case +const boundary = { + 'Design_elite_vs_vernacular': 5, + 'Entanglement_obligatory_vs_voluntary': 5, +}; +console.log(classifier.predict(boundary)); +// Expected: Recommend long form +``` + +## Performance + +- Model size: ~5KB (negligible) +- Classification time: < 1ms +- No network calls needed (runs entirely client-side) +- Works offline once model is loaded + +## Next Steps + +1. Integrate classifier into existing bicorder form +2. Design UI components for classification display +3. Add user preference for form selection +4. Consider adding classification to protocol browsing/search +5. Export classification data with completed diagnostics + +## Questions? + +See the demo in `bicorder-classifier.js` for working examples, or test with: + +```bash +node bicorder-classifier.js +``` diff --git a/analysis/README.md b/analysis/README.md index 6d28300..50b7bd1 100644 --- a/analysis/README.md +++ b/analysis/README.md @@ -383,3 +383,49 @@ Hypothesis: Changing the analyst and their standpoint could result in interestin Method: Alongside the dataset of protocols, generate diverse personas, such as a) personas used to evaluate every protocols, and b) protocol-specific personas that reflect different relationships to the protocol. Modify the test suite to include personas as an additional dimension of the analysis. +## Integration with Bicorder Tool + +The cluster analysis findings have been integrated into the bicorder system as an automated analysis gradient: + +**Bureaucratic ↔ Relational** - A new analysis field that automatically calculates where a protocol falls on the spectrum between the two protocol families identified through clustering analysis. + +### Implementation + +- **Model**: Linear Discriminant Analysis (LDA) trained on 406 protocols +- **Input**: The 23 diagnostic dimension values (read from bicorder.json in gradient order) +- **Output**: A value from 1-9 where: + - **1-3**: Strongly bureaucratic/institutional (formal, top-down, externally enforced) + - **4-6**: Mixed or boundary characteristics + - **7-9**: Strongly relational/cultural (emergent, voluntary, community-based) + +**Design philosophy**: The model includes a `bicorder_version` field matching the bicorder.json version it was trained on. The implementation checks versions match before calculating. When bicorder.json structure changes (gradients added/removed/reordered), increment the version and retrain the model. + +This simple version-matching approach ensures compatibility without complex structure mapping. + +### Files + +- `bicorder_model.json` (5KB) - Trained LDA model with coefficients and scaler parameters +- `bicorder-classifier.js` - JavaScript implementation for real-time classification in web app +- `ascii_bicorder.py` (updated) - Python script now calculates automated analysis values +- `../bicorder.json` (updated) - Added bureaucratic ↔ relational gradient to analysis section + +### Usage + +The calculation happens automatically when generating bicorder output: + +```bash +python3 ascii_bicorder.py bicorder.json bicorder.txt +``` + +For web integration, see `INTEGRATION_GUIDE.md` for details on using `bicorder-classifier.js` to provide real-time classification as users fill out diagnostics. + +### Key Features + +- **Automated**: Calculated from diagnostic values, no manual assessment needed +- **Data-driven**: Based on multivariate analysis of 406 protocols +- **Single metric**: Distance to boundary determines classification confidence +- **Form recommendation**: Can suggest short vs. long form based on boundary distance +- **Lightweight**: 5KB model, no dependencies, runs client-side + +The integration provides a data-backed way to understand where a protocol sits on the fundamental institutional/relational spectrum identified in the clustering analysis. + diff --git a/analysis/bicorder-classifier.d.ts b/analysis/bicorder-classifier.d.ts new file mode 100644 index 0000000..d242356 --- /dev/null +++ b/analysis/bicorder-classifier.d.ts @@ -0,0 +1,98 @@ +/** + * Type definitions for Bicorder Cluster Classifier + */ + +export interface ModelData { + version: string; + generated: string; + dimensions: string[]; + key_dimensions: string[]; + cluster_names: { + '1': string; + '2': string; + }; + cluster_descriptions: { + '1': string; + '2': string; + }; + scaler: { + mean: number[]; + scale: number[]; + }; + lda: { + coefficients: number[]; + intercept: number; + }; + cluster_centroids_scaled: { + '1': number[]; + '2': number[]; + }; + cluster_means_original: { + '1': number[]; + '2': number[]; + }; + thresholds: { + confidence_low: number; + completeness_low: number; + boundary_distance_low: number; + }; + metadata: { + total_protocols: number; + cluster_1_count: number; + cluster_2_count: number; + }; +} + +export interface Ratings { + [dimensionName: string]: number | null | undefined; +} + +export interface PredictionResult { + cluster: 1 | 2; + clusterName: string; + confidence: number; + completeness: number; + recommendedForm: 'short' | 'long'; +} + +export interface DetailedPredictionResult extends PredictionResult { + ldaScore: number; + distanceToBoundary: number; + dimensionsProvided: number; + dimensionsTotal: number; + keyDimensionsProvided: number; + keyDimensionsTotal: number; + distancesToCentroids: { + '1': number; + '2': number; + }; + rawConfidence: number; +} + +export interface ShortFormAssessment { + ready: boolean; + keyDimensionsProvided: number; + keyDimensionsTotal: number; + coverage: number; + missingKeyDimensions: string[]; +} + +export interface PredictOptions { + detailed?: boolean; +} + +export class BicorderClassifier { + constructor(model: ModelData); + + predict(ratings: Ratings, options?: { detailed: false }): PredictionResult; + predict(ratings: Ratings, options: { detailed: true }): DetailedPredictionResult; + predict(ratings: Ratings, options?: PredictOptions): PredictionResult | DetailedPredictionResult; + + explainClassification(ratings: Ratings): string; + + getKeyDimensions(): string[]; + + assessShortFormReadiness(ratings: Ratings): ShortFormAssessment; +} + +export function loadClassifier(url?: string): Promise; diff --git a/analysis/bicorder-classifier.js b/analysis/bicorder-classifier.js new file mode 100644 index 0000000..ea85b94 --- /dev/null +++ b/analysis/bicorder-classifier.js @@ -0,0 +1,335 @@ +/** + * Bicorder Cluster Classifier + * + * Real-time protocol classification for the Bicorder web app. + * Predicts which protocol family (Relational/Cultural vs Institutional/Bureaucratic) + * a protocol belongs to based on dimension ratings. + * + * Usage: + * import { BicorderClassifier } from './bicorder-classifier.js'; + * + * const classifier = new BicorderClassifier(modelData); + * const result = classifier.predict(ratings); + * console.log(`Cluster: ${result.clusterName} (${result.confidence}% confidence)`); + */ + +export class BicorderClassifier { + /** + * @param {Object} model - Model data loaded from bicorder_model.json + * @param {string} bicorderVersion - Version of bicorder.json being used + * + * Simple version-matching approach: The model includes a bicorder_version + * field. When bicorder structure changes, update the version and retrain. + */ + constructor(model, bicorderVersion = null) { + this.model = model; + this.dimensions = model.dimensions; + this.keyDimensions = model.key_dimensions; + this.bicorderVersion = bicorderVersion; + + // Check version compatibility + if (bicorderVersion && model.bicorder_version && bicorderVersion !== model.bicorder_version) { + console.warn(`Model version (${model.bicorder_version}) doesn't match bicorder version (${bicorderVersion}). Results may be inaccurate.`); + } + } + + /** + * Standardize values using the fitted scaler + * @private + */ + _standardize(values) { + return values.map((val, i) => { + if (val === null || val === undefined) return null; + return (val - this.model.scaler.mean[i]) / this.model.scaler.scale[i]; + }); + } + + /** + * Calculate LDA score (position on discriminant axis) + * @private + */ + _ldaScore(scaledValues) { + // Fill missing values with 0 (mean in scaled space) + const filled = scaledValues.map(v => v === null ? 0 : v); + + // Calculate: coef · x + intercept + let score = this.model.lda.intercept; + for (let i = 0; i < filled.length; i++) { + score += this.model.lda.coefficients[i] * filled[i]; + } + return score; + } + + /** + * Calculate Euclidean distance + * @private + */ + _distance(a, b) { + let sum = 0; + for (let i = 0; i < a.length; i++) { + const diff = a[i] - b[i]; + sum += diff * diff; + } + return Math.sqrt(sum); + } + + /** + * Predict cluster for given ratings + * + * @param {Object} ratings - Map of dimension names to values (1-9) + * Can be partial - missing dimensions handled gracefully + * @param {Object} options - Options + * @param {boolean} options.detailed - Return detailed information (default: true) + * + * @returns {Object} Prediction result with: + * - cluster: Cluster number (1 or 2) + * - clusterName: Human-readable name + * - confidence: Confidence percentage (0-100) + * - completeness: Percentage of dimensions provided (0-100) + * - recommendedForm: 'short' or 'long' + * - ldaScore: Position on discriminant axis + * - distanceToBoundary: Distance from cluster boundary + */ + predict(ratings, options = { detailed: true }) { + // Convert ratings object to array + const values = this.dimensions.map(dim => ratings[dim] ?? null); + const providedCount = values.filter(v => v !== null).length; + const completeness = providedCount / this.dimensions.length; + + // Fill missing with neutral value (5 = middle of 1-9 scale) + const filled = values.map(v => v ?? 5); + + // Standardize + const scaled = this._standardize(filled); + + // Calculate LDA score + const ldaScore = this._ldaScore(scaled); + + // Predict cluster (LDA boundary at 0) + // Positive score = cluster 2 (Institutional) + // Negative score = cluster 1 (Relational) + const cluster = ldaScore > 0 ? 2 : 1; + const clusterName = this.model.cluster_names[cluster]; + + // Calculate confidence based on distance from boundary + const distanceToBoundary = Math.abs(ldaScore); + + // Confidence: higher when further from boundary + // Normalize based on typical strong separation (3.0) + let confidence = Math.min(1.0, distanceToBoundary / 3.0); + + // Adjust for completeness + const adjustedConfidence = confidence * (0.5 + 0.5 * completeness); + + // Recommend form + // Use long form when: + // 1. Low confidence (< 0.6) + // 2. Low completeness (< 50% of dimensions) + // 3. Near boundary (< 0.5 distance) + const shouldUseLongForm = + adjustedConfidence < this.model.thresholds.confidence_low || + completeness < this.model.thresholds.completeness_low || + distanceToBoundary < this.model.thresholds.boundary_distance_low; + + const recommendedForm = shouldUseLongForm ? 'long' : 'short'; + + const basicResult = { + cluster, + clusterName, + confidence: Math.round(adjustedConfidence * 100), + completeness: Math.round(completeness * 100), + recommendedForm, + }; + + if (!options.detailed) { + return basicResult; + } + + // Calculate distances to cluster centroids + const filledScaled = scaled.map(v => v ?? 0); + const distances = {}; + for (const [clusterId, centroid] of Object.entries(this.model.cluster_centroids_scaled)) { + distances[clusterId] = this._distance(filledScaled, centroid); + } + + // Count key dimensions provided + const keyDimensionsProvided = this.keyDimensions.filter( + dim => ratings[dim] !== null && ratings[dim] !== undefined + ).length; + + return { + ...basicResult, + ldaScore, + distanceToBoundary, + dimensionsProvided: providedCount, + dimensionsTotal: this.dimensions.length, + keyDimensionsProvided, + keyDimensionsTotal: this.keyDimensions.length, + distancesToCentroids: distances, + rawConfidence: Math.round(confidence * 100), + }; + } + + /** + * Get explanation of classification + * + * @param {Object} ratings - Dimension ratings + * @returns {string} Human-readable explanation + */ + explainClassification(ratings) { + const result = this.predict(ratings, { detailed: true }); + const lines = []; + + lines.push(`Protocol Classification: ${result.clusterName}`); + lines.push(`Confidence: ${result.confidence}%`); + lines.push(''); + + if (result.cluster === 2) { + lines.push('This protocol leans toward Institutional/Bureaucratic characteristics:'); + lines.push(' • More likely to be formal, standardized, top-down'); + lines.push(' • May involve state/corporate enforcement'); + lines.push(' • Tends toward precise, documented procedures'); + } else { + lines.push('This protocol leans toward Relational/Cultural characteristics:'); + lines.push(' • More likely to be emergent, community-based'); + lines.push(' • May involve voluntary participation'); + lines.push(' • Tends toward interpretive, flexible practices'); + } + + lines.push(''); + lines.push(`Distance from boundary: ${result.distanceToBoundary.toFixed(2)}`); + + if (result.distanceToBoundary < 0.5) { + lines.push('⚠️ This protocol is near the boundary between families.'); + lines.push(' It may exhibit characteristics of both types.'); + } + + lines.push(''); + lines.push(`Completeness: ${result.completeness}% (${result.dimensionsProvided}/${result.dimensionsTotal} dimensions)`); + + if (result.completeness < 100) { + lines.push('Note: Missing dimensions filled with neutral values (5)'); + lines.push(' Confidence improves with complete data'); + } + + lines.push(''); + lines.push(`Recommended form: ${result.recommendedForm.toUpperCase()}`); + + if (result.recommendedForm === 'long') { + lines.push('Reason: Use long form for:'); + if (result.confidence < 60) { + lines.push(' • Low classification confidence'); + } + if (result.completeness < 50) { + lines.push(' • Incomplete data'); + } + if (result.distanceToBoundary < 0.5) { + lines.push(' • Ambiguous positioning between families'); + } + } else { + lines.push(`Reason: High confidence classification with ${result.completeness}% data`); + } + + return lines.join('\n'); + } + + /** + * Get the list of key dimensions for short form + * @returns {Array} Dimension names + */ + getKeyDimensions() { + return [...this.keyDimensions]; + } + + /** + * Check if enough key dimensions are provided for reliable short-form classification + * @param {Object} ratings - Current ratings + * @returns {Object} Assessment with recommendation + */ + assessShortFormReadiness(ratings) { + const keyProvided = this.keyDimensions.filter( + dim => ratings[dim] !== null && ratings[dim] !== undefined + ); + + const coverage = keyProvided.length / this.keyDimensions.length; + const isReady = coverage >= 0.75; // 75% of key dimensions + + return { + ready: isReady, + keyDimensionsProvided: keyProvided.length, + keyDimensionsTotal: this.keyDimensions.length, + coverage: Math.round(coverage * 100), + missingKeyDimensions: this.keyDimensions.filter( + dim => !ratings[dim] + ), + }; + } +} + +/** + * Load model from JSON file + * + * @param {string} url - URL to bicorder_model.json + * @returns {Promise} Initialized classifier + */ +export async function loadClassifier(url = './bicorder_model.json') { + const response = await fetch(url); + const model = await response.json(); + return new BicorderClassifier(model); +} + +// Example usage (for testing in Node.js or browser console) +if (typeof window === 'undefined' && typeof module !== 'undefined') { + // Node.js example + const fs = require('fs'); + + function demo() { + const modelData = JSON.parse(fs.readFileSync('bicorder_model.json', 'utf8')); + const classifier = new BicorderClassifier(modelData); + + console.log('='.repeat(80)); + console.log('BICORDER CLASSIFIER - DEMO'); + console.log('='.repeat(80)); + + // Example 1: Community protocol + console.log('\nExample 1: Community-Based Protocol'); + console.log('-'.repeat(80)); + const communityRatings = { + 'Design_elite_vs_vernacular': 9, + 'Design_explicit_vs_implicit': 8, + 'Entanglement_flocking_vs_swarming': 9, + 'Entanglement_obligatory_vs_voluntary': 9, + 'Design_static_vs_malleable': 8, + }; + console.log(classifier.explainClassification(communityRatings)); + + // Example 2: Institutional protocol + console.log('\n\n' + '='.repeat(80)); + console.log('Example 2: Institutional Protocol'); + console.log('-'.repeat(80)); + const institutionalRatings = { + 'Design_elite_vs_vernacular': 1, + 'Design_explicit_vs_implicit': 1, + 'Entanglement_flocking_vs_swarming': 1, + 'Entanglement_obligatory_vs_voluntary': 1, + }; + console.log(classifier.explainClassification(institutionalRatings)); + + // Example 3: Check short form readiness + console.log('\n\n' + '='.repeat(80)); + console.log('Example 3: Short Form Readiness Assessment'); + console.log('-'.repeat(80)); + const partialRatings = { + 'Design_elite_vs_vernacular': 5, + 'Entanglement_flocking_vs_swarming': 6, + }; + const assessment = classifier.assessShortFormReadiness(partialRatings); + console.log(`Ready for reliable classification: ${assessment.ready}`); + console.log(`Key dimensions coverage: ${assessment.coverage}% (${assessment.keyDimensionsProvided}/${assessment.keyDimensionsTotal})`); + console.log(`Missing key dimensions: ${assessment.missingKeyDimensions.length}`); + } + + if (require.main === module) { + demo(); + } +} diff --git a/analysis/bicorder_classifier.py b/analysis/bicorder_classifier.py new file mode 100644 index 0000000..3b571e1 --- /dev/null +++ b/analysis/bicorder_classifier.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 +""" +Bicorder Cluster Classifier + +Provides real-time protocol classification and smart form recommendation +based on the two-cluster analysis. + +Usage: + from bicorder_classifier import BicorderClassifier + + classifier = BicorderClassifier() + + # As user fills in dimensions + ratings = { + 'Design_explicit_vs_implicit': 7, + 'Design_elite_vs_vernacular': 2, + # ... etc + } + + result = classifier.predict(ratings) + print(f"Cluster: {result['cluster']}") + print(f"Confidence: {result['confidence']:.1%}") + print(f"Recommend form: {result['recommended_form']}") +""" + +import pandas as pd +import numpy as np +from sklearn.preprocessing import StandardScaler +from sklearn.discriminant_analysis import LinearDiscriminantAnalysis +import json +from pathlib import Path + + +class BicorderClassifier: + """ + Classifies protocols into one of two families and recommends form type. + """ + + # Dimension names (in order) + DIMENSIONS = [ + 'Design_explicit_vs_implicit', + 'Design_precise_vs_interpretive', + 'Design_elite_vs_vernacular', + 'Design_documenting_vs_enabling', + 'Design_static_vs_malleable', + 'Design_technical_vs_social', + 'Design_universal_vs_particular', + 'Design_durable_vs_ephemeral', + 'Entanglement_macro_vs_micro', + 'Entanglement_sovereign_vs_subsidiary', + 'Entanglement_self-enforcing_vs_enforced', + 'Entanglement_abstract_vs_embodied', + 'Entanglement_obligatory_vs_voluntary', + 'Entanglement_flocking_vs_swarming', + 'Entanglement_defensible_vs_exposed', + 'Entanglement_exclusive_vs_non-exclusive', + 'Experience_sufficient_vs_insufficient', + 'Experience_crystallized_vs_contested', + 'Experience_trust-evading_vs_trust-inducing', + 'Experience_predictable_vs_emergent', + 'Experience_exclusion_vs_inclusion', + 'Experience_Kafka_vs_Whitehead', + 'Experience_dead_vs_alive', + ] + + # Cluster names + CLUSTER_NAMES = { + 1: "Relational/Cultural", + 2: "Institutional/Bureaucratic" + } + + # Key dimensions for short form (most discriminative) + # Based on LDA analysis - top differentiating dimensions + KEY_DIMENSIONS = [ + 'Design_elite_vs_vernacular', # 4.602 difference + 'Entanglement_flocking_vs_swarming', # 4.079 difference + 'Design_static_vs_malleable', # 3.775 difference + 'Entanglement_obligatory_vs_voluntary', # 3.648 difference + 'Entanglement_self-enforcing_vs_enforced', # 3.628 difference + 'Design_explicit_vs_implicit', # High importance + 'Entanglement_sovereign_vs_subsidiary', # High importance + 'Design_technical_vs_social', # High importance + ] + + def __init__(self, model_path='analysis_results/data'): + """Initialize classifier with pre-computed model data.""" + self.model_path = Path(model_path) + self.scaler = StandardScaler() + self.lda = None + self.cluster_centroids = None + + # Load training data to fit scaler and LDA + self._load_model() + + def _load_model(self): + """Load and fit the classification model from analysis results.""" + # Load the original data and cluster assignments + df = pd.read_csv('diagnostic_output.csv') + clusters = pd.read_csv(self.model_path / 'kmeans_clusters.csv') + + # Remove duplicates + df = df.drop_duplicates(subset='Descriptor', keep='first') + + # Merge and clean + merged = df.merge(clusters, on='Descriptor') + merged_clean = merged.dropna(subset=self.DIMENSIONS) + + # Prepare training data + X = merged_clean[self.DIMENSIONS].values + y = merged_clean['cluster'].values + + # Fit scaler + self.scaler.fit(X) + X_scaled = self.scaler.transform(X) + + # Fit LDA + self.lda = LinearDiscriminantAnalysis(n_components=1) + self.lda.fit(X_scaled, y) + + # Calculate cluster centroids in scaled space + self.cluster_centroids = {} + for cluster_id in [1, 2]: + cluster_data = X_scaled[y == cluster_id] + self.cluster_centroids[cluster_id] = cluster_data.mean(axis=0) + + def predict(self, ratings, return_details=True): + """ + Predict cluster for given ratings. + + Args: + ratings: Dict mapping dimension names to values (1-9) + Can be partial - missing dimensions are filled with median + return_details: If True, returns detailed information + + Returns: + Dict with: + - cluster: Predicted cluster number (1 or 2) + - cluster_name: Human-readable cluster name + - confidence: Confidence score (0-1) + - completeness: Fraction of dimensions provided (0-1) + - recommended_form: 'short' or 'long' + - distance_to_boundary: How far from cluster boundary + - lda_score: Score on the discriminant axis + """ + # Convert ratings to full vector + X = np.full(len(self.DIMENSIONS), np.nan) + provided_count = 0 + + for i, dim in enumerate(self.DIMENSIONS): + if dim in ratings: + X[i] = ratings[dim] + provided_count += 1 + + completeness = provided_count / len(self.DIMENSIONS) + + # Fill missing values with median (5 - middle of 1-9 scale) + X[np.isnan(X)] = 5.0 + + # Scale + X_scaled = self.scaler.transform(X.reshape(1, -1)) + + # Predict cluster + cluster = self.lda.predict(X_scaled)[0] + + # Get LDA score (position on discriminant axis) + lda_score = self.lda.decision_function(X_scaled)[0] + + # Calculate confidence based on distance from decision boundary + # LDA decision boundary is at 0 + distance_to_boundary = abs(lda_score) + + # Confidence: higher when further from boundary + # Normalize based on observed data range + confidence = min(1.0, distance_to_boundary / 3.0) # 3.0 is typical strong separation + + # Adjust confidence based on completeness + adjusted_confidence = confidence * (0.5 + 0.5 * completeness) + + # Recommend form + # Use long form when: + # 1. Low confidence (< 0.6) + # 2. Low completeness (< 0.5 of dimensions provided) + # 3. Near boundary (< 0.5 distance) + if adjusted_confidence < 0.6 or completeness < 0.5 or distance_to_boundary < 0.5: + recommended_form = 'long' + else: + recommended_form = 'short' + + if not return_details: + return { + 'cluster': int(cluster), + 'cluster_name': self.CLUSTER_NAMES[cluster], + 'confidence': float(adjusted_confidence), + 'recommended_form': recommended_form + } + + # Calculate distances to each centroid + distances = {} + for cluster_id, centroid in self.cluster_centroids.items(): + dist = np.linalg.norm(X_scaled - centroid) + distances[cluster_id] = float(dist) + + return { + 'cluster': int(cluster), + 'cluster_name': self.CLUSTER_NAMES[cluster], + 'confidence': float(adjusted_confidence), + 'completeness': float(completeness), + 'dimensions_provided': provided_count, + 'dimensions_total': len(self.DIMENSIONS), + 'recommended_form': recommended_form, + 'distance_to_boundary': float(distance_to_boundary), + 'lda_score': float(lda_score), + 'distances_to_centroids': distances, + 'key_dimensions_provided': sum(1 for dim in self.KEY_DIMENSIONS if dim in ratings), + 'key_dimensions_total': len(self.KEY_DIMENSIONS), + } + + def get_key_dimensions(self): + """Return the most important dimensions for classification.""" + return self.KEY_DIMENSIONS.copy() + + def get_short_form_dimensions(self): + """Return recommended dimensions for short form.""" + return self.KEY_DIMENSIONS + + def explain_classification(self, ratings): + """ + Provide human-readable explanation of classification. + + Args: + ratings: Dict mapping dimension names to values + + Returns: + String explanation + """ + result = self.predict(ratings, return_details=True) + + explanation = [] + explanation.append(f"Protocol Classification: {result['cluster_name']}") + explanation.append(f"Confidence: {result['confidence']:.0%}") + explanation.append(f"") + + if result['lda_score'] > 0: + explanation.append(f"This protocol leans toward Institutional/Bureaucratic characteristics:") + explanation.append(f" - More likely to be formal, standardized, top-down") + explanation.append(f" - May involve state/corporate enforcement") + explanation.append(f" - Tends toward precise, documented procedures") + else: + explanation.append(f"This protocol leans toward Relational/Cultural characteristics:") + explanation.append(f" - More likely to be emergent, community-based") + explanation.append(f" - May involve voluntary participation") + explanation.append(f" - Tends toward interpretive, flexible practices") + + explanation.append(f"") + explanation.append(f"Distance from boundary: {result['distance_to_boundary']:.2f}") + + if result['distance_to_boundary'] < 0.5: + explanation.append(f"⚠️ This protocol is near the boundary between families.") + explanation.append(f" It may exhibit characteristics of both types.") + + explanation.append(f"") + explanation.append(f"Completeness: {result['completeness']:.0%} ({result['dimensions_provided']}/{result['dimensions_total']} dimensions)") + + if result['completeness'] < 1.0: + explanation.append(f"Note: Missing dimensions filled with neutral values (5)") + explanation.append(f" Confidence improves with complete data") + + explanation.append(f"") + explanation.append(f"Recommended form: {result['recommended_form'].upper()}") + + if result['recommended_form'] == 'long': + explanation.append(f"Reason: Use long form for:") + if result['confidence'] < 0.6: + explanation.append(f" - Low classification confidence") + if result['completeness'] < 0.5: + explanation.append(f" - Incomplete data") + if result['distance_to_boundary'] < 0.5: + explanation.append(f" - Ambiguous positioning between families") + else: + explanation.append(f"Reason: High confidence classification with {result['completeness']:.0%} data") + + return "\n".join(explanation) + + def save_model(self, output_path='bicorder_classifier_model.json'): + """Save model parameters for use without scikit-learn.""" + model_data = { + 'dimensions': self.DIMENSIONS, + 'key_dimensions': self.KEY_DIMENSIONS, + 'cluster_names': self.CLUSTER_NAMES, + 'scaler_mean': self.scaler.mean_.tolist(), + 'scaler_std': self.scaler.scale_.tolist(), + 'lda_coef': self.lda.coef_.tolist(), + 'lda_intercept': self.lda.intercept_.tolist(), + 'cluster_centroids': { + str(k): v.tolist() for k, v in self.cluster_centroids.items() + } + } + + with open(output_path, 'w') as f: + json.dump(model_data, f, indent=2) + + print(f"Model saved to {output_path}") + return output_path + + +def main(): + """Demo usage of the classifier.""" + print("=" * 80) + print("BICORDER CLUSTER CLASSIFIER - DEMO") + print("=" * 80) + + classifier = BicorderClassifier() + + # Example 1: Relational/Cultural protocol (e.g., Indigenous knowledge sharing) + print("\nExample 1: Community-Based Protocol") + print("-" * 80) + ratings_relational = { + 'Design_elite_vs_vernacular': 9, # Very vernacular + 'Design_explicit_vs_implicit': 8, # More implicit + 'Entanglement_flocking_vs_swarming': 9, # Swarming + 'Entanglement_obligatory_vs_voluntary': 9, # Voluntary + 'Design_static_vs_malleable': 8, # Malleable + 'Design_technical_vs_social': 9, # Social + } + + print(classifier.explain_classification(ratings_relational)) + + # Example 2: Institutional protocol (e.g., Airport security) + print("\n\n" + "=" * 80) + print("Example 2: Institutional Protocol") + print("-" * 80) + ratings_institutional = { + 'Design_elite_vs_vernacular': 1, # Elite + 'Design_explicit_vs_implicit': 1, # Very explicit + 'Entanglement_flocking_vs_swarming': 1, # Flocking + 'Entanglement_obligatory_vs_voluntary': 1, # Obligatory + 'Design_static_vs_malleable': 2, # Static + 'Design_technical_vs_social': 2, # Technical + 'Entanglement_sovereign_vs_subsidiary': 1, # Sovereign + } + + print(classifier.explain_classification(ratings_institutional)) + + # Example 3: Ambiguous/boundary protocol + print("\n\n" + "=" * 80) + print("Example 3: Boundary Protocol (mixed characteristics)") + print("-" * 80) + ratings_boundary = { + 'Design_elite_vs_vernacular': 5, # Middle + 'Design_explicit_vs_implicit': 4, # Slightly implicit + 'Entanglement_flocking_vs_swarming': 5, # Middle + 'Entanglement_obligatory_vs_voluntary': 6, # Slightly voluntary + } + + print(classifier.explain_classification(ratings_boundary)) + + # Save model + print("\n\n" + "=" * 80) + classifier.save_model() + print("\nKey dimensions for short form:") + for dim in classifier.get_key_dimensions(): + print(f" - {dim}") + + +if __name__ == '__main__': + main() diff --git a/analysis/bicorder_model.json b/analysis/bicorder_model.json new file mode 100644 index 0000000..864564a --- /dev/null +++ b/analysis/bicorder_model.json @@ -0,0 +1,242 @@ +{ + "version": "1.0", + "generated": "2025-12-19T11:46:23.367069", + "dimensions": [ + "Design_explicit_vs_implicit", + "Design_precise_vs_interpretive", + "Design_elite_vs_vernacular", + "Design_documenting_vs_enabling", + "Design_static_vs_malleable", + "Design_technical_vs_social", + "Design_universal_vs_particular", + "Design_durable_vs_ephemeral", + "Entanglement_macro_vs_micro", + "Entanglement_sovereign_vs_subsidiary", + "Entanglement_self-enforcing_vs_enforced", + "Entanglement_abstract_vs_embodied", + "Entanglement_obligatory_vs_voluntary", + "Entanglement_flocking_vs_swarming", + "Entanglement_defensible_vs_exposed", + "Entanglement_exclusive_vs_non-exclusive", + "Experience_sufficient_vs_insufficient", + "Experience_crystallized_vs_contested", + "Experience_trust-evading_vs_trust-inducing", + "Experience_predictable_vs_emergent", + "Experience_exclusion_vs_inclusion", + "Experience_Kafka_vs_Whitehead", + "Experience_dead_vs_alive" + ], + "key_dimensions": [ + "Design_elite_vs_vernacular", + "Entanglement_flocking_vs_swarming", + "Design_static_vs_malleable", + "Entanglement_obligatory_vs_voluntary", + "Entanglement_self-enforcing_vs_enforced", + "Design_explicit_vs_implicit", + "Entanglement_sovereign_vs_subsidiary", + "Design_technical_vs_social" + ], + "cluster_names": { + "1": "Relational/Cultural", + "2": "Institutional/Bureaucratic" + }, + "cluster_descriptions": { + "1": "Community-based, emergent, voluntary, cultural protocols", + "2": "Formal, institutional, top-down, bureaucratic protocols" + }, + "scaler": { + "mean": [ + 4.369458128078818, + 6.9926108374384235, + 4.280788177339901, + 7.071428571428571, + 5.495073891625616, + 7.605911330049261, + 4.906403940886699, + 2.8448275862068964, + 4.0, + 4.334975369458128, + 5.1330049261083746, + 4.938423645320197, + 6.458128078817734, + 4.5, + 6.098522167487685, + 8.54679802955665, + 4.768472906403941, + 4.477832512315271, + 5.470443349753695, + 4.0344827586206895, + 5.95320197044335, + 6.6600985221674875, + 6.261083743842365 + ], + "scale": [ + 3.0955956638838664, + 2.3650669037872776, + 3.1167709970002604, + 2.6396598953510204, + 2.9342088618205646, + 2.259596836619051, + 3.301903969981493, + 2.4105801980966635, + 3.0754069777539583, + 3.0923564395371748, + 2.965656347489423, + 2.947596746358859, + 3.2795786169534162, + 3.0776086295770764, + 2.404869399944494, + 1.5745910700958754, + 2.614018881400845, + 2.5185520962654415, + 2.694249175776989, + 2.5985583542877566, + 2.60073447253778, + 2.3378938755950007, + 2.7184487042280177 + ] + }, + "lda": { + "coefficients": [ + -0.8113131401967797, + -0.6780978846606565, + -1.7916346902015383, + -0.33082387528450047, + -1.3556757099424177, + 0.00522394587646953, + 0.2091556037617108, + 0.0052787328424997604, + -0.5102939584967334, + 0.5243704699495828, + 0.917023369248283, + -0.4320525790345405, + -0.9876536429868208, + -1.6466008241797736, + 0.020355535751261943, + 0.18873795734844703, + 0.007631434551345332, + -0.05274873290777075, + 0.22072662646149233, + -0.14939472173014767, + -0.8694685683555488, + -0.33816085503600546, + 0.17541614725190025 + ], + "intercept": -0.5210332756593339 + }, + "cluster_centroids_scaled": { + "1": [ + 0.2874410058011442, + 0.4122427799369615, + 0.6897418450969605, + 0.2430367611616426, + 0.599669560302629, + 0.07196292092201756, + -0.04316147692163484, + 0.14887541089907227, + 0.32967633104265, + -0.4661354145692694, + -0.5709326748957033, + 0.27690557920369735, + 0.5153162240398965, + 0.6197693199442068, + 0.19774541877299637, + 0.2290177968739523, + -0.008837937705021599, + 0.15585854215092942, + 0.256692548521944, + 0.48380052961733855, + 0.4861667484706564, + 0.3216307304274786, + 0.014656219932196797 + ], + "2": [ + -0.3267750381739341, + -0.46865494982307176, + -0.784127571268124, + -0.27629442321534037, + -0.6817296053966734, + -0.0818104785218722, + 0.04906778428985856, + -0.16924783554841838, + -0.37478993423795953, + 0.529922366036642, + 0.649060304091958, + -0.3147979216210447, + -0.5858331810137729, + -0.7045798584628877, + -0.22480531818403862, + -0.26035707434091443, + 0.01004733970676111, + -0.17718655318210969, + -0.2918188972670522, + -0.550004812617605, + -0.5526948298403266, + -0.365643356696502, + -0.016661807922918572 + ] + }, + "cluster_means_original": { + "1": [ + 5.2592592592592595, + 7.967592592592593, + 6.430555555555555, + 7.712962962962963, + 7.25462962962963, + 7.768518518518518, + 4.763888888888889, + 3.2037037037037037, + 5.013888888888889, + 2.8935185185185186, + 3.439814814814815, + 5.75462962962963, + 8.148148148148149, + 6.407407407407407, + 6.574074074074074, + 8.907407407407407, + 4.74537037037037, + 4.87037037037037, + 6.162037037037037, + 5.291666666666667, + 7.217592592592593, + 7.412037037037037, + 6.300925925925926 + ], + "2": [ + 3.357894736842105, + 5.88421052631579, + 1.8368421052631578, + 6.342105263157895, + 3.4947368421052634, + 7.421052631578948, + 5.068421052631579, + 2.4368421052631577, + 2.8473684210526318, + 5.973684210526316, + 7.057894736842106, + 4.010526315789473, + 4.536842105263158, + 2.331578947368421, + 5.557894736842106, + 8.136842105263158, + 4.794736842105263, + 4.031578947368421, + 4.684210526315789, + 2.6052631578947367, + 4.515789473684211, + 5.8052631578947365, + 6.21578947368421 + ] + }, + "thresholds": { + "confidence_low": 0.6, + "completeness_low": 0.5, + "boundary_distance_low": 0.5 + }, + "metadata": { + "total_protocols": 406, + "cluster_1_count": 216, + "cluster_2_count": 190 + }, + "bicorder_version": "1.2.3" +} \ No newline at end of file diff --git a/analysis/export_model_for_js.py b/analysis/export_model_for_js.py new file mode 100644 index 0000000..24cc5c9 --- /dev/null +++ b/analysis/export_model_for_js.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +""" +Export the cluster classification model to JSON for use in JavaScript. +""" + +import pandas as pd +import numpy as np +from sklearn.preprocessing import StandardScaler +from sklearn.discriminant_analysis import LinearDiscriminantAnalysis +import json + +# Dimension names +DIMENSIONS = [ + 'Design_explicit_vs_implicit', + 'Design_precise_vs_interpretive', + 'Design_elite_vs_vernacular', + 'Design_documenting_vs_enabling', + 'Design_static_vs_malleable', + 'Design_technical_vs_social', + 'Design_universal_vs_particular', + 'Design_durable_vs_ephemeral', + 'Entanglement_macro_vs_micro', + 'Entanglement_sovereign_vs_subsidiary', + 'Entanglement_self-enforcing_vs_enforced', + 'Entanglement_abstract_vs_embodied', + 'Entanglement_obligatory_vs_voluntary', + 'Entanglement_flocking_vs_swarming', + 'Entanglement_defensible_vs_exposed', + 'Entanglement_exclusive_vs_non-exclusive', + 'Experience_sufficient_vs_insufficient', + 'Experience_crystallized_vs_contested', + 'Experience_trust-evading_vs_trust-inducing', + 'Experience_predictable_vs_emergent', + 'Experience_exclusion_vs_inclusion', + 'Experience_Kafka_vs_Whitehead', + 'Experience_dead_vs_alive', +] + +# Load data +df = pd.read_csv('diagnostic_output.csv') +clusters = pd.read_csv('analysis_results/data/kmeans_clusters.csv') + +# Remove duplicates +df = df.drop_duplicates(subset='Descriptor', keep='first') + +# Merge and clean +merged = df.merge(clusters, on='Descriptor') +merged_clean = merged.dropna(subset=DIMENSIONS) + +# Prepare training data +X = merged_clean[DIMENSIONS].values +y = merged_clean['cluster'].values + +# Fit scaler +scaler = StandardScaler() +X_scaled = scaler.fit_transform(X) + +# Fit LDA +lda = LinearDiscriminantAnalysis(n_components=1) +lda.fit(X_scaled, y) + +# Calculate cluster centroids +cluster_centroids = {} +for cluster_id in [1, 2]: + cluster_data = X_scaled[y == cluster_id] + cluster_centroids[cluster_id] = cluster_data.mean(axis=0).tolist() + +# Calculate cluster means in original space (for reference) +cluster_means_original = {} +for cluster_id in [1, 2]: + cluster_data_original = X[y == cluster_id] + cluster_means_original[cluster_id] = cluster_data_original.mean(axis=0).tolist() + +# Key dimensions (most discriminative) +KEY_DIMENSIONS = [ + 'Design_elite_vs_vernacular', + 'Entanglement_flocking_vs_swarming', + 'Design_static_vs_malleable', + 'Entanglement_obligatory_vs_voluntary', + 'Entanglement_self-enforcing_vs_enforced', + 'Design_explicit_vs_implicit', + 'Entanglement_sovereign_vs_subsidiary', + 'Design_technical_vs_social', +] + +# Build model export +model = { + 'version': '1.0', + 'generated': pd.Timestamp.now().isoformat(), + 'dimensions': DIMENSIONS, + 'key_dimensions': KEY_DIMENSIONS, + 'cluster_names': { + '1': 'Relational/Cultural', + '2': 'Institutional/Bureaucratic' + }, + 'cluster_descriptions': { + '1': 'Community-based, emergent, voluntary, cultural protocols', + '2': 'Formal, institutional, top-down, bureaucratic protocols' + }, + 'scaler': { + 'mean': scaler.mean_.tolist(), + 'scale': scaler.scale_.tolist() + }, + 'lda': { + 'coefficients': lda.coef_[0].tolist(), + 'intercept': lda.intercept_[0] + }, + 'cluster_centroids_scaled': cluster_centroids, + 'cluster_means_original': cluster_means_original, + 'thresholds': { + 'confidence_low': 0.6, + 'completeness_low': 0.5, + 'boundary_distance_low': 0.5 + }, + 'metadata': { + 'total_protocols': len(merged_clean), + 'cluster_1_count': int((y == 1).sum()), + 'cluster_2_count': int((y == 2).sum()), + } +} + +# Save to JSON +output_path = 'bicorder_model.json' +with open(output_path, 'w') as f: + json.dump(model, f, indent=2) + +print(f"Model exported to {output_path}") +print(f"Total dimensions: {len(DIMENSIONS)}") +print(f"Key dimensions for short form: {len(KEY_DIMENSIONS)}") +print(f"Model size: {len(json.dumps(model))} bytes") diff --git a/analysis/test-classifier.mjs b/analysis/test-classifier.mjs new file mode 100644 index 0000000..75d9b42 --- /dev/null +++ b/analysis/test-classifier.mjs @@ -0,0 +1,37 @@ +import { BicorderClassifier } from './bicorder-classifier.js'; +import fs from 'fs'; + +const modelData = JSON.parse(fs.readFileSync('bicorder_model.json', 'utf8')); +const classifier = new BicorderClassifier(modelData); + +console.log('='.repeat(80)); +console.log('BICORDER CLASSIFIER - TEST'); +console.log('='.repeat(80)); + +// Test 1 +console.log('\nTest 1: Institutional Protocol (e.g., Airport Security)'); +console.log('-'.repeat(80)); +const institutional = { + 'Design_elite_vs_vernacular': 1, + 'Design_explicit_vs_implicit': 1, + 'Entanglement_flocking_vs_swarming': 1, + 'Entanglement_obligatory_vs_voluntary': 1, +}; +const result1 = classifier.predict(institutional); +console.log(JSON.stringify(result1, null, 2)); + +// Test 2 +console.log('\n\nTest 2: Relational Protocol (e.g., Indigenous Practices)'); +console.log('-'.repeat(80)); +const relational = { + 'Design_elite_vs_vernacular': 9, + 'Entanglement_flocking_vs_swarming': 9, + 'Entanglement_obligatory_vs_voluntary': 9, +}; +const result2 = classifier.predict(relational); +console.log(JSON.stringify(result2, null, 2)); + +console.log('\n\n' + '='.repeat(80)); +console.log('✓ JavaScript classifier working correctly!'); +console.log(' Model size:', Math.round(fs.statSync('bicorder_model.json').size / 1024), 'KB'); +console.log('='.repeat(80)); diff --git a/ascii_bicorder.py b/ascii_bicorder.py index 8f2e052..0170ee2 100644 --- a/ascii_bicorder.py +++ b/ascii_bicorder.py @@ -6,6 +6,181 @@ Generate bicorder.txt from bicorder.json import json import argparse import sys +import os +from pathlib import Path + + +# Simple version-based approach +# +# The model includes a 'bicorder_version' field indicating which version of +# bicorder.json it was trained on. The code checks that versions match before +# calculating. This ensures the gradient structure is compatible. +# +# When bicorder.json changes (gradients added/removed/reordered), update the +# version number and retrain the model. + + +def load_classifier_model(): + """Load the LDA model from bicorder_model.json""" + # Try to find the model file + script_dir = Path(__file__).parent + model_paths = [ + script_dir / 'analysis' / 'bicorder_model.json', + script_dir / 'bicorder_model.json', + Path('analysis/bicorder_model.json'), + Path('bicorder_model.json'), + ] + + for path in model_paths: + if path.exists(): + with open(path, 'r') as f: + return json.load(f) + + return None + + +def calculate_lda_score(values_array, model): + """ + Calculate LDA score from an array of values using the model. + + Args: + values_array: list of 23 values (1-9) in the order expected by the model + model: loaded classifier model + + Returns: + LDA score (float), or None if insufficient data + """ + if model is None: + return None + + if len(values_array) != len(model['dimensions']): + return None + + # Standardize using model scaler + mean = model['scaler']['mean'] + scale = model['scaler']['scale'] + scaled = [(values_array[i] - mean[i]) / scale[i] for i in range(len(values_array))] + + # Calculate LDA score: coef · x + intercept + coef = model['lda']['coefficients'] + intercept = model['lda']['intercept'] + + # Dot product + lda_score = sum(coef[i] * scaled[i] for i in range(len(scaled))) + intercept + + return lda_score + + +def lda_score_to_scale(lda_score): + """ + Convert LDA score to 1-9 scale. + LDA scores typically range from -4 to +4 (8 range) + Target scale is 1 to 9 (8 range) + + Formula: value = 5 + (lda_score * 4/3) + - LDA -3 or less → 1 (bureaucratic) + - LDA 0 → 5 (boundary) + - LDA +3 or more → 9 (relational) + """ + if lda_score is None: + return None + + # Scale: value = 5 + (lda_score * 1.33) + value = 5 + (lda_score * 4.0 / 3.0) + + # Clamp to 1-9 range and round + value = max(1, min(9, value)) + return round(value) + + +def calculate_hardness(diagnostic_values): + """Calculate hardness/softness (mean of all diagnostic values)""" + if not diagnostic_values: + return None + + valid_values = [v for v in diagnostic_values if v is not None] + if not valid_values: + return None + + return round(sum(valid_values) / len(valid_values)) + + +def calculate_polarization(diagnostic_values): + """ + Calculate polarization (1 = extreme, 9 = centrist). + Measures how far values are from the center (5). + """ + if not diagnostic_values: + return None + + valid_values = [v for v in diagnostic_values if v is not None] + if not valid_values: + return None + + # Calculate mean distance from center + distances = [abs(v - 5) for v in valid_values] + mean_distance = sum(distances) / len(distances) + + # Convert to 1-9 scale (inverted: high distance = low value = polarized) + # Maximum possible distance is 4 (from 1 or 9 to 5) + # Scale: 1 (all at extremes) to 9 (all at center) + polarization = 9 - (mean_distance / 4 * 8) + + return round(max(1, min(9, polarization))) + + +def calculate_automated_analysis(json_data): + """ + Calculate values for automated analysis fields. + Modifies json_data in place. + """ + # Collect all diagnostic values in order + diagnostic_values = [] + values_array = [] + + for diagnostic_set in json_data.get("diagnostic", []): + for gradient in diagnostic_set.get("gradients", []): + value = gradient.get("value") + if value is not None: + diagnostic_values.append(value) + values_array.append(float(value)) + else: + # Fill missing with neutral value + values_array.append(5.0) + + # Only calculate if we have diagnostic values + if not diagnostic_values: + return + + # Load classifier model + model = load_classifier_model() + + # Check version compatibility + bicorder_version = json_data.get("version", "unknown") + model_version = model.get("bicorder_version", "unknown") if model else "unknown" + + version_mismatch = (model and bicorder_version != model_version) + + # Calculate each automated analysis field + for analysis_item in json_data.get("analysis", []): + if not analysis_item.get("automated", False): + continue + + term_left = analysis_item.get("term_left", "") + + # Calculate based on the type + if term_left == "hardness": + analysis_item["value"] = calculate_hardness(diagnostic_values) + elif term_left == "polarized": + analysis_item["value"] = calculate_polarization(diagnostic_values) + elif term_left == "bureaucratic": + if version_mismatch: + # Skip calculation if versions don't match + print(f"Warning: Model version ({model_version}) doesn't match bicorder version ({bicorder_version}). Skipping bureaucratic/relational calculation.") + analysis_item["value"] = None + elif model: + lda_score = calculate_lda_score(values_array, model) + analysis_item["value"] = lda_score_to_scale(lda_score) def center_text(text, width): @@ -218,6 +393,9 @@ def main(): print(f"Error: Invalid JSON in '{args.input_json}': {e}", file=sys.stderr) sys.exit(1) + # Calculate automated analysis values + calculate_automated_analysis(data) + # Generate the formatted text output = generate_bicorder_text(data) diff --git a/bicorder-app/src/App.svelte b/bicorder-app/src/App.svelte index b49f698..f690ba9 100644 --- a/bicorder-app/src/App.svelte +++ b/bicorder-app/src/App.svelte @@ -6,9 +6,15 @@ import AnalysisDisplay from './components/AnalysisDisplay.svelte'; import ExportControls from './components/ExportControls.svelte'; import HelpModal from './components/HelpModal.svelte'; + import FormRecommendation from './components/FormRecommendation.svelte'; + import { BicorderClassifier } from './bicorder-classifier'; - // Load bicorder data from build-time constant + // Load bicorder data and model from build-time constants let data: BicorderState = JSON.parse(JSON.stringify(__BICORDER_DATA__)); + const model = __BICORDER_MODEL__; + + // Initialize classifier + const classifier = new BicorderClassifier(model, data.version); // Initialize timestamp if not set if (!data.metadata.timestamp) { @@ -30,7 +36,7 @@ | { type: 'export' }; // Calculate all screens based on current shortform setting - function calculateScreens(): Screen[] { + function calculateScreens(isShortForm: boolean): Screen[] { const screens: Screen[] = []; // Metadata screen @@ -39,7 +45,7 @@ // Diagnostic gradient screens data.diagnostic.forEach((diagnosticSet, setIndex) => { diagnosticSet.gradients.forEach((gradient, gradientIndex) => { - if (!data.metadata.shortform || gradient.shortform) { + if (!isShortForm || gradient.shortform) { screens.push({ type: 'gradient', setIndex, @@ -52,7 +58,7 @@ }); // Analysis screens (not in shortform) - if (!data.metadata.shortform) { + if (!isShortForm) { data.analysis.forEach((gradient, index) => { screens.push({ type: 'analysis', index, gradient }); }); @@ -64,10 +70,16 @@ return screens; } - $: screens = calculateScreens(); + // Recalculate screens when data or shortform changes (explicit dependency) + $: screens = calculateScreens(data.metadata.shortform); $: currentScreenData = screens[currentScreen]; $: totalScreens = screens.length; + // Debug: log when screens change + $: if (screens) { + console.log(`Screens updated: ${screens.length} total, shortform: ${data.metadata.shortform}`); + } + function goToNextScreen() { if (currentScreen < totalScreens - 1) { currentScreen++; @@ -188,7 +200,59 @@ // Max deviation is 4 (from 1 or 9), min is 0 (at 5) // Higher deviation = more polarized = lower value const polarizationScore = 9 - (avgDeviation / 4) * 8; - return Math.round(polarizationScore); + + // Clamp to 1-9 range and round + return Math.round(Math.max(1, Math.min(9, polarizationScore))); + } + + function ldaScoreToScale(ldaScore: number | null): number | null { + /** + * Convert LDA score to 1-9 scale. + * LDA scores typically range from -4 to +4 (8 range) + * Target scale is 1 to 9 (8 range) + * + * Formula: value = 5 + (ldaScore * 4/3) + * - LDA -3 or less → 1 (bureaucratic) + * - LDA 0 → 5 (boundary) + * - LDA +3 or more → 9 (relational) + */ + if (ldaScore === null) return null; + + // Scale: value = 5 + (ldaScore * 1.33) + const value = 5 + (ldaScore * 4.0 / 3.0); + + // Clamp to 1-9 range and round + return Math.round(Math.max(1, Math.min(9, value))); + } + + function calculateBureaucratic(): number | null { + // Collect all diagnostic gradients with their set and gradient info + const ratings: Record = {}; + + data.diagnostic.forEach((diagnosticSet) => { + const setName = diagnosticSet.set_name; + diagnosticSet.gradients.forEach((gradient) => { + if (gradient.value !== null) { + // Create dimension name in format: SetName_left_vs_right + const dimensionName = `${setName}_${gradient.term_left}_vs_${gradient.term_right}`; + ratings[dimensionName] = gradient.value; + } + }); + }); + + // Check if we have any ratings + if (Object.keys(ratings).length === 0) return null; + + try { + // Get prediction from classifier (need detailed: true to get ldaScore) + const result = classifier.predict(ratings, { detailed: true }); + + // Convert LDA score to 1-9 scale + return ldaScoreToScale(result.ldaScore); + } catch (error) { + console.error('Error calculating bureaucratic/relational score:', error); + return null; + } } // Update automated analysis values reactively @@ -196,16 +260,29 @@ data.analysis.forEach((item, index) => { if (item.automated) { if (index === 0) { + // Hardness/Softness data.analysis[0].value = calculateHardness(); } else if (index === 1) { + // Polarized/Centrist data.analysis[1].value = calculatePolarization(); + } else if (index === 2) { + // Bureaucratic/Relational (LDA classifier) + data.analysis[2].value = calculateBureaucratic(); } } }); } function handleMetadataUpdate(event: CustomEvent) { - data.metadata = { ...data.metadata, ...event.detail }; + // Properly trigger reactivity for nested metadata changes + data = { + ...data, + metadata: { ...data.metadata, ...event.detail } + }; + // Force refresh if shortform changed + if (event.detail.shortform !== undefined) { + refreshKey++; + } } function handleReset() { @@ -215,21 +292,79 @@ } } + function handleProgressBarClick(event: MouseEvent) { + // Calculate which screen to jump to based on click position + const target = event.currentTarget as HTMLElement; + const rect = target.getBoundingClientRect(); + const x = event.clientX - rect.left; + const width = rect.width; + + // Calculate ratio (0 to 1) + const ratio = Math.max(0, Math.min(1, x / width)); + + // Calculate target screen (round to nearest) + const targetScreen = Math.round(ratio * (totalScreens - 1)); + + // Jump to that screen + currentScreen = targetScreen; + } + function openHelp() { isHelpOpen = true; } + + function handleSwitchToLongForm() { + // Turn off shortform mode while preserving all entered values + data = { + ...data, + metadata: { + ...data.metadata, + shortform: false + } + }; + // Force refresh of components + refreshKey++; + // Reset to first screen to show user the full form + currentScreen = 0; + }
- -
Protocol
-
BICORDER
- +
+ +
+ +
+
Protocol
+
BICORDER
+
+ +
+ + +
{#if viewMode === 'list'} @@ -407,7 +542,18 @@
-
{progressBar}
+
+ {progressBar} +
{currentScreen + 1} / {totalScreens}
@@ -429,11 +575,31 @@ } .header { - text-align: center; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; margin-bottom: 1rem; border-bottom: 2px solid var(--border-color); padding-bottom: 1rem; - position: relative; + gap: 1rem; + } + + .header-left { + display: flex; + justify-content: flex-start; + align-items: center; + gap: 0.5rem; + } + + .header-center { + text-align: center; + } + + .header-right { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 0.5rem; } .title { @@ -442,51 +608,37 @@ letter-spacing: 0.2rem; } - .help-btn { - position: absolute; - top: 0; - left: 0; + .toolbar-btn { width: 2rem; height: 2rem; padding: 0; - font-size: 1.2rem; + font-size: 1rem; font-weight: bold; cursor: pointer; background: var(--bg-color); color: var(--fg-color); border: 1px solid var(--border-color); - border-radius: 50%; display: flex; align-items: center; justify-content: center; opacity: 0.4; transition: opacity 0.2s, background-color 0.2s; + min-height: auto; + flex-shrink: 0; + border-radius: 3px; } - .help-btn:hover { + .toolbar-btn.help-btn { + font-size: 1.2rem; + } + + .toolbar-btn:hover { opacity: 0.8; background-color: var(--input-bg); } .mode-toggle { - position: absolute; - top: 0; - right: 0; - padding: 0.3rem 0.6rem; font-size: 0.9rem; - cursor: pointer; - background: var(--bg-color); - color: var(--fg-color); - border: 1px solid var(--border-color); - min-height: auto; - opacity: 0.4; - transition: opacity 0.2s; - } - - .mode-toggle:hover { - opacity: 0.8; - background-color: var(--input-bg); - border-color: var(--border-color); } .description { @@ -657,6 +809,23 @@ color: var(--fg-color); } + .progress-bar.clickable { + cursor: pointer; + user-select: none; + transition: opacity 0.2s, transform 0.1s; + padding: 0.5rem; + margin: -0.5rem; + } + + .progress-bar.clickable:hover { + opacity: 0.7; + transform: scale(1.05); + } + + .progress-bar.clickable:active { + transform: scale(0.98); + } + .progress-numbers { font-size: 0.9rem; font-weight: bold; @@ -669,6 +838,11 @@ padding: 0.5rem; } + .header { + gap: 0.5rem; + padding-bottom: 0.75rem; + } + .title { font-size: 1.2rem; } @@ -677,15 +851,18 @@ font-size: 1rem; } - .help-btn { - width: 1.75rem; - height: 1.75rem; - font-size: 1rem; + .toolbar-btn { + width: 1.5rem; + height: 1.5rem; + font-size: 0.85rem; + } + + .toolbar-btn.help-btn { + font-size: 0.9rem; } .mode-toggle { - font-size: 0.85rem; - padding: 0.25rem 0.5rem; + font-size: 0.75rem; } .description { diff --git a/bicorder-app/src/bicorder-classifier.ts b/bicorder-app/src/bicorder-classifier.ts new file mode 100644 index 0000000..d6dfc79 --- /dev/null +++ b/bicorder-app/src/bicorder-classifier.ts @@ -0,0 +1,268 @@ +/** + * Bicorder Cluster Classifier + * + * Real-time protocol classification for the Bicorder web app. + * Predicts which protocol family (Relational/Cultural vs Institutional/Bureaucratic) + * a protocol belongs to based on dimension ratings. + * + * Usage: + * import { BicorderClassifier } from './bicorder-classifier.js'; + * + * const classifier = new BicorderClassifier(modelData); + * const result = classifier.predict(ratings); + * console.log(`Cluster: ${result.clusterName} (${result.confidence}% confidence)`); + */ + +export class BicorderClassifier { + /** + * @param {Object} model - Model data loaded from bicorder_model.json + * @param {string} bicorderVersion - Version of bicorder.json being used + * + * Simple version-matching approach: The model includes a bicorder_version + * field. When bicorder structure changes, update the version and retrain. + */ + constructor(model, bicorderVersion = null) { + this.model = model; + this.dimensions = model.dimensions; + this.keyDimensions = model.key_dimensions; + this.bicorderVersion = bicorderVersion; + + // Check version compatibility + if (bicorderVersion && model.bicorder_version && bicorderVersion !== model.bicorder_version) { + console.warn(`Model version (${model.bicorder_version}) doesn't match bicorder version (${bicorderVersion}). Results may be inaccurate.`); + } + } + + /** + * Standardize values using the fitted scaler + * @private + */ + _standardize(values) { + return values.map((val, i) => { + if (val === null || val === undefined) return null; + return (val - this.model.scaler.mean[i]) / this.model.scaler.scale[i]; + }); + } + + /** + * Calculate LDA score (position on discriminant axis) + * @private + */ + _ldaScore(scaledValues) { + // Fill missing values with 0 (mean in scaled space) + const filled = scaledValues.map(v => v === null ? 0 : v); + + // Calculate: coef · x + intercept + let score = this.model.lda.intercept; + for (let i = 0; i < filled.length; i++) { + score += this.model.lda.coefficients[i] * filled[i]; + } + return score; + } + + /** + * Calculate Euclidean distance + * @private + */ + _distance(a, b) { + let sum = 0; + for (let i = 0; i < a.length; i++) { + const diff = a[i] - b[i]; + sum += diff * diff; + } + return Math.sqrt(sum); + } + + /** + * Predict cluster for given ratings + * + * @param {Object} ratings - Map of dimension names to values (1-9) + * Can be partial - missing dimensions handled gracefully + * @param {Object} options - Options + * @param {boolean} options.detailed - Return detailed information (default: true) + * + * @returns {Object} Prediction result with: + * - cluster: Cluster number (1 or 2) + * - clusterName: Human-readable name + * - confidence: Confidence percentage (0-100) + * - completeness: Percentage of dimensions provided (0-100) + * - recommendedForm: 'short' or 'long' + * - ldaScore: Position on discriminant axis + * - distanceToBoundary: Distance from cluster boundary + */ + predict(ratings, options = { detailed: true }) { + // Convert ratings object to array + const values = this.dimensions.map(dim => ratings[dim] ?? null); + const providedCount = values.filter(v => v !== null).length; + const completeness = providedCount / this.dimensions.length; + + // Fill missing with neutral value (5 = middle of 1-9 scale) + const filled = values.map(v => v ?? 5); + + // Standardize + const scaled = this._standardize(filled); + + // Calculate LDA score + const ldaScore = this._ldaScore(scaled); + + // Predict cluster (LDA boundary at 0) + // Positive score = cluster 2 (Institutional) + // Negative score = cluster 1 (Relational) + const cluster = ldaScore > 0 ? 2 : 1; + const clusterName = this.model.cluster_names[cluster]; + + // Calculate confidence based on distance from boundary + const distanceToBoundary = Math.abs(ldaScore); + + // Confidence: higher when further from boundary + // Normalize based on typical strong separation (3.0) + let confidence = Math.min(1.0, distanceToBoundary / 3.0); + + // Adjust for completeness + const adjustedConfidence = confidence * (0.5 + 0.5 * completeness); + + // Recommend form + // Use long form when: + // 1. Low confidence (< 0.6) + // 2. Low completeness (< 50% of dimensions) + // 3. Near boundary (< 0.5 distance) + const shouldUseLongForm = + adjustedConfidence < this.model.thresholds.confidence_low || + completeness < this.model.thresholds.completeness_low || + distanceToBoundary < this.model.thresholds.boundary_distance_low; + + const recommendedForm = shouldUseLongForm ? 'long' : 'short'; + + const basicResult = { + cluster, + clusterName, + confidence: Math.round(adjustedConfidence * 100), + completeness: Math.round(completeness * 100), + recommendedForm, + }; + + if (!options.detailed) { + return basicResult; + } + + // Calculate distances to cluster centroids + const filledScaled = scaled.map(v => v ?? 0); + const distances = {}; + for (const [clusterId, centroid] of Object.entries(this.model.cluster_centroids_scaled)) { + distances[clusterId] = this._distance(filledScaled, centroid); + } + + // Count key dimensions provided + const keyDimensionsProvided = this.keyDimensions.filter( + dim => ratings[dim] !== null && ratings[dim] !== undefined + ).length; + + return { + ...basicResult, + ldaScore, + distanceToBoundary, + dimensionsProvided: providedCount, + dimensionsTotal: this.dimensions.length, + keyDimensionsProvided, + keyDimensionsTotal: this.keyDimensions.length, + distancesToCentroids: distances, + rawConfidence: Math.round(confidence * 100), + }; + } + + /** + * Get explanation of classification + * + * @param {Object} ratings - Dimension ratings + * @returns {string} Human-readable explanation + */ + explainClassification(ratings) { + const result = this.predict(ratings, { detailed: true }); + const lines = []; + + lines.push(`Protocol Classification: ${result.clusterName}`); + lines.push(`Confidence: ${result.confidence}%`); + lines.push(''); + + if (result.cluster === 2) { + lines.push('This protocol leans toward Institutional/Bureaucratic characteristics:'); + lines.push(' • More likely to be formal, standardized, top-down'); + lines.push(' • May involve state/corporate enforcement'); + lines.push(' • Tends toward precise, documented procedures'); + } else { + lines.push('This protocol leans toward Relational/Cultural characteristics:'); + lines.push(' • More likely to be emergent, community-based'); + lines.push(' • May involve voluntary participation'); + lines.push(' • Tends toward interpretive, flexible practices'); + } + + lines.push(''); + lines.push(`Distance from boundary: ${result.distanceToBoundary.toFixed(2)}`); + + if (result.distanceToBoundary < 0.5) { + lines.push('⚠️ This protocol is near the boundary between families.'); + lines.push(' It may exhibit characteristics of both types.'); + } + + lines.push(''); + lines.push(`Completeness: ${result.completeness}% (${result.dimensionsProvided}/${result.dimensionsTotal} dimensions)`); + + if (result.completeness < 100) { + lines.push('Note: Missing dimensions filled with neutral values (5)'); + lines.push(' Confidence improves with complete data'); + } + + lines.push(''); + lines.push(`Recommended form: ${result.recommendedForm.toUpperCase()}`); + + if (result.recommendedForm === 'long') { + lines.push('Reason: Use long form for:'); + if (result.confidence < 60) { + lines.push(' • Low classification confidence'); + } + if (result.completeness < 50) { + lines.push(' • Incomplete data'); + } + if (result.distanceToBoundary < 0.5) { + lines.push(' • Ambiguous positioning between families'); + } + } else { + lines.push(`Reason: High confidence classification with ${result.completeness}% data`); + } + + return lines.join('\n'); + } + + /** + * Get the list of key dimensions for short form + * @returns {Array} Dimension names + */ + getKeyDimensions() { + return [...this.keyDimensions]; + } + + /** + * Check if enough key dimensions are provided for reliable short-form classification + * @param {Object} ratings - Current ratings + * @returns {Object} Assessment with recommendation + */ + assessShortFormReadiness(ratings) { + const keyProvided = this.keyDimensions.filter( + dim => ratings[dim] !== null && ratings[dim] !== undefined + ); + + const coverage = keyProvided.length / this.keyDimensions.length; + const isReady = coverage >= 0.75; // 75% of key dimensions + + return { + ready: isReady, + keyDimensionsProvided: keyProvided.length, + keyDimensionsTotal: this.keyDimensions.length, + coverage: Math.round(coverage * 100), + missingKeyDimensions: this.keyDimensions.filter( + dim => !ratings[dim] + ), + }; + } +} + diff --git a/bicorder-app/src/components/AnalysisDisplay.svelte b/bicorder-app/src/components/AnalysisDisplay.svelte index d40e1f2..f9881f9 100644 --- a/bicorder-app/src/components/AnalysisDisplay.svelte +++ b/bicorder-app/src/components/AnalysisDisplay.svelte @@ -78,7 +78,7 @@ function renderBar(value: number | null): string { // Fixed scale with 9 positions using ||||#|||| if (value === null) { - return '||||·||||'; + return '||||+||||'; } // Value is 1-9, position the # marker at the right spot const positions = [ diff --git a/bicorder-app/src/components/FormRecommendation.svelte b/bicorder-app/src/components/FormRecommendation.svelte new file mode 100644 index 0000000..af1ffa3 --- /dev/null +++ b/bicorder-app/src/components/FormRecommendation.svelte @@ -0,0 +1,450 @@ + + +{#if showIndicator} +
+ + + {#if isExpanded} +
{}} role="button" tabindex="-1"> +
{}} role="dialog" aria-modal="true"> +
+

Data Quality Assessment

+ +
+ +
+
+ Classification Confidence: + + {recommendation.confidence}% + +
+ +
+ Data Completeness: + + {recommendation.completeness}% ({recommendation.dimensionsProvided}/{recommendation.dimensionsTotal} dimensions) + +
+ +
+ Key Dimensions: + + {recommendation.coverage}% ({recommendation.keyDimensionsProvided}/{recommendation.keyDimensionsTotal}) + +
+ +
+
Current Classification:
+
+ {recommendation.clusterName} + {#if recommendation.distanceToBoundary < 0.5} + (Near boundary) + {/if} +
+
+ + {#if recommendation.recommendedForm === 'long'} +
+ ⚠ Long Form Recommended +

+ {#if recommendation.confidence < 60} + • Low classification confidence
+ {/if} + {#if recommendation.completeness < 50} + • Incomplete data (less than 50% of dimensions)
+ {/if} + {#if recommendation.distanceToBoundary < 0.5} + • Protocol near boundary between families
+ {/if} + {#if recommendation.coverage < 75} + • Missing key dimensions for reliable short-form classification
+ {/if} +

+ +

All your current values will be preserved.

+
+ {:else} +
+ ✓ Short Form Working Well +

+ Your current data provides {recommendation.confidence}% confidence classification. + Continue with short form or switch to long form for more detailed analysis. +

+
+ {/if} +
+
+
+ {/if} +
+{/if} + + diff --git a/bicorder-app/src/components/GradientSlider.svelte b/bicorder-app/src/components/GradientSlider.svelte index ba9717e..54a821e 100644 --- a/bicorder-app/src/components/GradientSlider.svelte +++ b/bicorder-app/src/components/GradientSlider.svelte @@ -74,7 +74,7 @@ function renderBar(value: number | null): string { // Slider-style visualization with brackets and value number if (value === null) { - return '[----X----]'; + return '[----+----]'; } // Value is 1-9, show the number at its position along the slider const bars = [ diff --git a/bicorder-app/src/vite-env.d.ts b/bicorder-app/src/vite-env.d.ts index 6e3eca6..bcf8be3 100644 --- a/bicorder-app/src/vite-env.d.ts +++ b/bicorder-app/src/vite-env.d.ts @@ -2,6 +2,7 @@ /// declare const __BICORDER_DATA__: any +declare const __BICORDER_MODEL__: any interface ImportMetaEnv { readonly VITE_APP_TITLE: string diff --git a/bicorder-app/vite.config.ts b/bicorder-app/vite.config.ts index a1da4b0..6cb5c09 100644 --- a/bicorder-app/vite.config.ts +++ b/bicorder-app/vite.config.ts @@ -9,6 +9,11 @@ const bicorderData = JSON.parse( fs.readFileSync(path.resolve(__dirname, '../bicorder.json'), 'utf-8') ) +// Read bicorder_model.json at build time +const bicorderModel = JSON.parse( + fs.readFileSync(path.resolve(__dirname, '../analysis/bicorder_model.json'), 'utf-8') +) + export default defineConfig({ base: './', plugins: [ @@ -62,6 +67,7 @@ export default defineConfig({ }) ], define: { - '__BICORDER_DATA__': JSON.stringify(bicorderData) + '__BICORDER_DATA__': JSON.stringify(bicorderData), + '__BICORDER_MODEL__': JSON.stringify(bicorderModel) } }) diff --git a/bicorder.json b/bicorder.json index 949aaa6..b8ff791 100644 --- a/bicorder.json +++ b/bicorder.json @@ -264,6 +264,16 @@ "value": null, "notes": null }, + { + "term_left": "bureaucratic", + "term_left_description": "The protocol exhibits institutional, formal, top-down characteristics with centralized control and external enforcement", + "term_right": "relational", + "term_right_description": "The protocol exhibits community-based, emergent, bottom-up characteristics with distributed coordination and voluntary participation", + "instructions": "Based on the diagnostic readings, calculate the protocol's position using Linear Discriminant Analysis. The LDA score is scaled to the 1-9 range, where 1 represents strongly bureaucratic/institutional protocols and 9 represents strongly relational/cultural protocols. A score of 5 indicates a protocol near the boundary exhibiting characteristics of both families.", + "automated": true, + "value": null, + "notes": null + }, { "term_left": "not useful", "term_left_description": "The bicorder was not useful or relevant for analyzing this protocol",