Added classifer analysis to bicorder ascii and web app
This commit is contained in:
402
analysis/INTEGRATION_GUIDE.md
Normal file
402
analysis/INTEGRATION_GUIDE.md
Normal file
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<DiagnosticQuestions onChange={setRatings} />
|
||||||
|
|
||||||
|
{classification && (
|
||||||
|
<ClassificationIndicator
|
||||||
|
cluster={classification.clusterName}
|
||||||
|
confidence={classification.confidence}
|
||||||
|
completeness={classification.completeness}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 <Form onDimensionComplete={handleDimensionComplete} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 (
|
||||||
|
<div>
|
||||||
|
<h2>Quick Classification (8 questions)</h2>
|
||||||
|
{shortFormDimensions.map(dim => (
|
||||||
|
<DimensionSlider key={dim} dimension={dim} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 (
|
||||||
|
<div className="status-warning">
|
||||||
|
<p>
|
||||||
|
Need {assessment.keyDimensionsTotal - assessment.keyDimensionsProvided} more
|
||||||
|
key dimensions for reliable classification ({assessment.coverage}% complete)
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
{assessment.missingKeyDimensions.slice(0, 3).map(dim => (
|
||||||
|
<li key={dim}>{formatDimensionName(dim)}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ClassificationResult result={classifier.predict(ratings)} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI Components
|
||||||
|
|
||||||
|
### Classification Indicator
|
||||||
|
|
||||||
|
Visual indicator showing cluster and confidence:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function ClassificationIndicator({ cluster, confidence, completeness }) {
|
||||||
|
const color = cluster === 1 ? '#2E86AB' : '#A23B72';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="classification-indicator" style={{ borderColor: color }}>
|
||||||
|
<div className="cluster-badge" style={{ backgroundColor: color }}>
|
||||||
|
{cluster === 1 ? 'Relational/Cultural' : 'Institutional/Bureaucratic'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="confidence-bar">
|
||||||
|
<div
|
||||||
|
className="confidence-fill"
|
||||||
|
style={{
|
||||||
|
width: `${confidence}%`,
|
||||||
|
backgroundColor: color,
|
||||||
|
opacity: 0.3 + (confidence / 100) * 0.7,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="confidence-text">{confidence}% confidence</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="completeness">
|
||||||
|
{completeness}% of dimensions provided
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 (
|
||||||
|
<div className="spectrum">
|
||||||
|
<div className="spectrum-bar">
|
||||||
|
<div className="spectrum-label left">Relational/Cultural</div>
|
||||||
|
<div className="spectrum-label right">Institutional/Bureaucratic</div>
|
||||||
|
|
||||||
|
<div className="spectrum-track">
|
||||||
|
{boundaryZone && (
|
||||||
|
<div className="boundary-zone" style={{ left: '45%', width: '10%' }}>
|
||||||
|
Boundary
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="protocol-marker"
|
||||||
|
style={{ left: `${position}%` }}
|
||||||
|
title={`LDA Score: ${ldaScore.toFixed(2)}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
@@ -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.
|
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.
|
||||||
|
|
||||||
|
|||||||
98
analysis/bicorder-classifier.d.ts
vendored
Normal file
98
analysis/bicorder-classifier.d.ts
vendored
Normal file
@@ -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<BicorderClassifier>;
|
||||||
335
analysis/bicorder-classifier.js
Normal file
335
analysis/bicorder-classifier.js
Normal file
@@ -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<string>} 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<BicorderClassifier>} 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
366
analysis/bicorder_classifier.py
Normal file
366
analysis/bicorder_classifier.py
Normal file
@@ -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()
|
||||||
242
analysis/bicorder_model.json
Normal file
242
analysis/bicorder_model.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
130
analysis/export_model_for_js.py
Normal file
130
analysis/export_model_for_js.py
Normal file
@@ -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")
|
||||||
37
analysis/test-classifier.mjs
Normal file
37
analysis/test-classifier.mjs
Normal file
@@ -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));
|
||||||
@@ -6,6 +6,181 @@ Generate bicorder.txt from bicorder.json
|
|||||||
import json
|
import json
|
||||||
import argparse
|
import argparse
|
||||||
import sys
|
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):
|
def center_text(text, width):
|
||||||
@@ -218,6 +393,9 @@ def main():
|
|||||||
print(f"Error: Invalid JSON in '{args.input_json}': {e}", file=sys.stderr)
|
print(f"Error: Invalid JSON in '{args.input_json}': {e}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Calculate automated analysis values
|
||||||
|
calculate_automated_analysis(data)
|
||||||
|
|
||||||
# Generate the formatted text
|
# Generate the formatted text
|
||||||
output = generate_bicorder_text(data)
|
output = generate_bicorder_text(data)
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,15 @@
|
|||||||
import AnalysisDisplay from './components/AnalysisDisplay.svelte';
|
import AnalysisDisplay from './components/AnalysisDisplay.svelte';
|
||||||
import ExportControls from './components/ExportControls.svelte';
|
import ExportControls from './components/ExportControls.svelte';
|
||||||
import HelpModal from './components/HelpModal.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__));
|
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
|
// Initialize timestamp if not set
|
||||||
if (!data.metadata.timestamp) {
|
if (!data.metadata.timestamp) {
|
||||||
@@ -30,7 +36,7 @@
|
|||||||
| { type: 'export' };
|
| { type: 'export' };
|
||||||
|
|
||||||
// Calculate all screens based on current shortform setting
|
// Calculate all screens based on current shortform setting
|
||||||
function calculateScreens(): Screen[] {
|
function calculateScreens(isShortForm: boolean): Screen[] {
|
||||||
const screens: Screen[] = [];
|
const screens: Screen[] = [];
|
||||||
|
|
||||||
// Metadata screen
|
// Metadata screen
|
||||||
@@ -39,7 +45,7 @@
|
|||||||
// Diagnostic gradient screens
|
// Diagnostic gradient screens
|
||||||
data.diagnostic.forEach((diagnosticSet, setIndex) => {
|
data.diagnostic.forEach((diagnosticSet, setIndex) => {
|
||||||
diagnosticSet.gradients.forEach((gradient, gradientIndex) => {
|
diagnosticSet.gradients.forEach((gradient, gradientIndex) => {
|
||||||
if (!data.metadata.shortform || gradient.shortform) {
|
if (!isShortForm || gradient.shortform) {
|
||||||
screens.push({
|
screens.push({
|
||||||
type: 'gradient',
|
type: 'gradient',
|
||||||
setIndex,
|
setIndex,
|
||||||
@@ -52,7 +58,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Analysis screens (not in shortform)
|
// Analysis screens (not in shortform)
|
||||||
if (!data.metadata.shortform) {
|
if (!isShortForm) {
|
||||||
data.analysis.forEach((gradient, index) => {
|
data.analysis.forEach((gradient, index) => {
|
||||||
screens.push({ type: 'analysis', index, gradient });
|
screens.push({ type: 'analysis', index, gradient });
|
||||||
});
|
});
|
||||||
@@ -64,10 +70,16 @@
|
|||||||
return screens;
|
return screens;
|
||||||
}
|
}
|
||||||
|
|
||||||
$: screens = calculateScreens();
|
// Recalculate screens when data or shortform changes (explicit dependency)
|
||||||
|
$: screens = calculateScreens(data.metadata.shortform);
|
||||||
$: currentScreenData = screens[currentScreen];
|
$: currentScreenData = screens[currentScreen];
|
||||||
$: totalScreens = screens.length;
|
$: totalScreens = screens.length;
|
||||||
|
|
||||||
|
// Debug: log when screens change
|
||||||
|
$: if (screens) {
|
||||||
|
console.log(`Screens updated: ${screens.length} total, shortform: ${data.metadata.shortform}`);
|
||||||
|
}
|
||||||
|
|
||||||
function goToNextScreen() {
|
function goToNextScreen() {
|
||||||
if (currentScreen < totalScreens - 1) {
|
if (currentScreen < totalScreens - 1) {
|
||||||
currentScreen++;
|
currentScreen++;
|
||||||
@@ -188,7 +200,59 @@
|
|||||||
// Max deviation is 4 (from 1 or 9), min is 0 (at 5)
|
// Max deviation is 4 (from 1 or 9), min is 0 (at 5)
|
||||||
// Higher deviation = more polarized = lower value
|
// Higher deviation = more polarized = lower value
|
||||||
const polarizationScore = 9 - (avgDeviation / 4) * 8;
|
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<string, number> = {};
|
||||||
|
|
||||||
|
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
|
// Update automated analysis values reactively
|
||||||
@@ -196,16 +260,29 @@
|
|||||||
data.analysis.forEach((item, index) => {
|
data.analysis.forEach((item, index) => {
|
||||||
if (item.automated) {
|
if (item.automated) {
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
|
// Hardness/Softness
|
||||||
data.analysis[0].value = calculateHardness();
|
data.analysis[0].value = calculateHardness();
|
||||||
} else if (index === 1) {
|
} else if (index === 1) {
|
||||||
|
// Polarized/Centrist
|
||||||
data.analysis[1].value = calculatePolarization();
|
data.analysis[1].value = calculatePolarization();
|
||||||
|
} else if (index === 2) {
|
||||||
|
// Bureaucratic/Relational (LDA classifier)
|
||||||
|
data.analysis[2].value = calculateBureaucratic();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMetadataUpdate(event: CustomEvent) {
|
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() {
|
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() {
|
function openHelp() {
|
||||||
isHelpOpen = true;
|
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;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<HelpModal bind:isOpen={isHelpOpen} />
|
<HelpModal bind:isOpen={isHelpOpen} />
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<button class="help-btn" on:click={openHelp} aria-label="About the Bicorder">?</button>
|
<div class="header-left">
|
||||||
<div class="title">Protocol</div>
|
<button
|
||||||
<div class="title">BICORDER</div>
|
class="toolbar-btn help-btn"
|
||||||
<button class="mode-toggle" on:click={toggleViewMode} aria-label="Toggle view mode">
|
on:click={openHelp}
|
||||||
{viewMode === 'focused' ? '☰' : '⊡'}
|
aria-label="About the Bicorder"
|
||||||
</button>
|
title="Help & Instructions"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-center">
|
||||||
|
<div class="title">Protocol</div>
|
||||||
|
<div class="title">BICORDER</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-right">
|
||||||
|
<FormRecommendation
|
||||||
|
{classifier}
|
||||||
|
diagnosticData={data.diagnostic}
|
||||||
|
isShortForm={data.metadata.shortform}
|
||||||
|
on:switchToLongForm={handleSwitchToLongForm}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="toolbar-btn mode-toggle"
|
||||||
|
on:click={toggleViewMode}
|
||||||
|
aria-label="Toggle view mode"
|
||||||
|
title={viewMode === 'focused' ? 'Switch to list view' : 'Switch to focused view'}
|
||||||
|
>
|
||||||
|
{viewMode === 'focused' ? '☰' : '⊡'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if viewMode === 'list'}
|
{#if viewMode === 'list'}
|
||||||
@@ -407,7 +542,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="progress-indicator">
|
<div class="progress-indicator">
|
||||||
<div class="progress-bar">{progressBar}</div>
|
<div
|
||||||
|
class="progress-bar clickable"
|
||||||
|
on:click={handleProgressBarClick}
|
||||||
|
role="slider"
|
||||||
|
aria-label="Jump to screen"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={totalScreens - 1}
|
||||||
|
aria-valuenow={currentScreen}
|
||||||
|
title="Click to jump to a screen"
|
||||||
|
>
|
||||||
|
{progressBar}
|
||||||
|
</div>
|
||||||
<div class="progress-numbers">{currentScreen + 1} / {totalScreens}</div>
|
<div class="progress-numbers">{currentScreen + 1} / {totalScreens}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -429,11 +575,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
text-align: center;
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
border-bottom: 2px solid var(--border-color);
|
border-bottom: 2px solid var(--border-color);
|
||||||
padding-bottom: 1rem;
|
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 {
|
.title {
|
||||||
@@ -442,51 +608,37 @@
|
|||||||
letter-spacing: 0.2rem;
|
letter-spacing: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-btn {
|
.toolbar-btn {
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 1.2rem;
|
font-size: 1rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
color: var(--fg-color);
|
color: var(--fg-color);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
transition: opacity 0.2s, background-color 0.2s;
|
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;
|
opacity: 0.8;
|
||||||
background-color: var(--input-bg);
|
background-color: var(--input-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-toggle {
|
.mode-toggle {
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
padding: 0.3rem 0.6rem;
|
|
||||||
font-size: 0.9rem;
|
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 {
|
.description {
|
||||||
@@ -657,6 +809,23 @@
|
|||||||
color: var(--fg-color);
|
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 {
|
.progress-numbers {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
@@ -669,6 +838,11 @@
|
|||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
}
|
}
|
||||||
@@ -677,15 +851,18 @@
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-btn {
|
.toolbar-btn {
|
||||||
width: 1.75rem;
|
width: 1.5rem;
|
||||||
height: 1.75rem;
|
height: 1.5rem;
|
||||||
font-size: 1rem;
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn.help-btn {
|
||||||
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-toggle {
|
.mode-toggle {
|
||||||
font-size: 0.85rem;
|
font-size: 0.75rem;
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
|
|||||||
268
bicorder-app/src/bicorder-classifier.ts
Normal file
268
bicorder-app/src/bicorder-classifier.ts
Normal file
@@ -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<string>} 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]
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
function renderBar(value: number | null): string {
|
function renderBar(value: number | null): string {
|
||||||
// Fixed scale with 9 positions using ||||#||||
|
// Fixed scale with 9 positions using ||||#||||
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
return '||||·||||';
|
return '||||+||||';
|
||||||
}
|
}
|
||||||
// Value is 1-9, position the # marker at the right spot
|
// Value is 1-9, position the # marker at the right spot
|
||||||
const positions = [
|
const positions = [
|
||||||
|
|||||||
450
bicorder-app/src/components/FormRecommendation.svelte
Normal file
450
bicorder-app/src/components/FormRecommendation.svelte
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import type { BicorderClassifier } from '../bicorder-classifier';
|
||||||
|
|
||||||
|
export let classifier: BicorderClassifier;
|
||||||
|
export let diagnosticData: any;
|
||||||
|
export let isShortForm: boolean;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{
|
||||||
|
switchToLongForm: void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let isExpanded = false;
|
||||||
|
let recommendation: any = null;
|
||||||
|
let hasEnoughData = false;
|
||||||
|
|
||||||
|
// Calculate recommendation based on current diagnostic data
|
||||||
|
$: {
|
||||||
|
// Collect ratings from diagnostic data
|
||||||
|
const ratings: Record<string, number> = {};
|
||||||
|
let valueCount = 0;
|
||||||
|
let shortFormTotal = 0;
|
||||||
|
|
||||||
|
diagnosticData.forEach((diagnosticSet: any) => {
|
||||||
|
const setName = diagnosticSet.set_name;
|
||||||
|
diagnosticSet.gradients.forEach((gradient: any) => {
|
||||||
|
// Count shortform gradients
|
||||||
|
if (gradient.shortform) {
|
||||||
|
shortFormTotal++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gradient.value !== null) {
|
||||||
|
const dimensionName = `${setName}_${gradient.term_left}_vs_${gradient.term_right}`;
|
||||||
|
ratings[dimensionName] = gradient.value;
|
||||||
|
|
||||||
|
// Only count shortform values for the threshold
|
||||||
|
if (gradient.shortform) {
|
||||||
|
valueCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only show if at least half of shortform gradients are complete
|
||||||
|
const threshold = Math.ceil(shortFormTotal / 2);
|
||||||
|
hasEnoughData = valueCount >= threshold;
|
||||||
|
|
||||||
|
if (hasEnoughData && isShortForm) {
|
||||||
|
try {
|
||||||
|
const prediction = classifier.predict(ratings, { detailed: true });
|
||||||
|
const assessment = classifier.assessShortFormReadiness(ratings);
|
||||||
|
recommendation = {
|
||||||
|
...prediction,
|
||||||
|
...assessment,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting form recommendation:', error);
|
||||||
|
recommendation = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
recommendation = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExpanded() {
|
||||||
|
isExpanded = !isExpanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSwitchToLongForm() {
|
||||||
|
dispatch('switchToLongForm');
|
||||||
|
isExpanded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine status: 'good' (green) or 'warning' (yellow/orange)
|
||||||
|
$: status = recommendation?.recommendedForm === 'long' ? 'warning' : 'good';
|
||||||
|
$: showIndicator = hasEnoughData && isShortForm && recommendation;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if showIndicator}
|
||||||
|
<div class="form-recommendation" class:expanded={isExpanded}>
|
||||||
|
<button
|
||||||
|
class="indicator"
|
||||||
|
class:good={status === 'good'}
|
||||||
|
class:warning={status === 'warning'}
|
||||||
|
on:click={toggleExpanded}
|
||||||
|
aria-label="Data quality indicator"
|
||||||
|
title={status === 'good' ? 'Short form working well' : 'Long form recommended'}
|
||||||
|
>
|
||||||
|
<span class="light"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if isExpanded}
|
||||||
|
<div class="panel-backdrop" on:click={toggleExpanded} on:keydown={() => {}} role="button" tabindex="-1">
|
||||||
|
<div class="details-panel" on:click|stopPropagation on:keydown={() => {}} role="dialog" aria-modal="true">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3>Data Quality Assessment</h3>
|
||||||
|
<button class="close-btn" on:click={toggleExpanded} aria-label="Close">+</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Classification Confidence:</span>
|
||||||
|
<span class="metric-value" class:low={recommendation.confidence < 60}>
|
||||||
|
{recommendation.confidence}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Data Completeness:</span>
|
||||||
|
<span class="metric-value" class:low={recommendation.completeness < 50}>
|
||||||
|
{recommendation.completeness}% ({recommendation.dimensionsProvided}/{recommendation.dimensionsTotal} dimensions)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Key Dimensions:</span>
|
||||||
|
<span class="metric-value" class:low={recommendation.coverage < 75}>
|
||||||
|
{recommendation.coverage}% ({recommendation.keyDimensionsProvided}/{recommendation.keyDimensionsTotal})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="classification">
|
||||||
|
<div class="classification-label">Current Classification:</div>
|
||||||
|
<div class="classification-value">
|
||||||
|
<strong>{recommendation.clusterName}</strong>
|
||||||
|
{#if recommendation.distanceToBoundary < 0.5}
|
||||||
|
<span class="boundary-warning">(Near boundary)</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if recommendation.recommendedForm === 'long'}
|
||||||
|
<div class="recommendation-message warning">
|
||||||
|
<strong>⚠ Long Form Recommended</strong>
|
||||||
|
<p>
|
||||||
|
{#if recommendation.confidence < 60}
|
||||||
|
• Low classification confidence<br>
|
||||||
|
{/if}
|
||||||
|
{#if recommendation.completeness < 50}
|
||||||
|
• Incomplete data (less than 50% of dimensions)<br>
|
||||||
|
{/if}
|
||||||
|
{#if recommendation.distanceToBoundary < 0.5}
|
||||||
|
• Protocol near boundary between families<br>
|
||||||
|
{/if}
|
||||||
|
{#if recommendation.coverage < 75}
|
||||||
|
• Missing key dimensions for reliable short-form classification<br>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<button class="switch-btn" on:click={handleSwitchToLongForm}>
|
||||||
|
Switch to Long Form →
|
||||||
|
</button>
|
||||||
|
<p class="note">All your current values will be preserved.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="recommendation-message good">
|
||||||
|
<strong>✓ Short Form Working Well</strong>
|
||||||
|
<p>
|
||||||
|
Your current data provides {recommendation.confidence}% confidence classification.
|
||||||
|
Continue with short form or switch to long form for more detailed analysis.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.form-recommendation {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-color);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
padding: 0;
|
||||||
|
opacity: 0.4;
|
||||||
|
min-height: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator.good .light {
|
||||||
|
background: #4ade80;
|
||||||
|
box-shadow: 0 0 8px rgba(74, 222, 128, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator.warning .light {
|
||||||
|
background: #fbbf24;
|
||||||
|
box-shadow: 0 0 8px rgba(251, 191, 36, 0.5);
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-backdrop {
|
||||||
|
/* Hidden on desktop - only visible on mobile */
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.5rem);
|
||||||
|
right: 0;
|
||||||
|
width: 400px;
|
||||||
|
max-width: calc(100vw - 2rem);
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||||
|
animation: slideIn 0.2s ease-out;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--fg-color);
|
||||||
|
opacity: 0.6;
|
||||||
|
padding: 0;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
padding: 1rem;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value.low {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification {
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-value {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-value strong {
|
||||||
|
color: var(--fg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.boundary-warning {
|
||||||
|
color: #fbbf24;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation-message {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 2px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation-message.good {
|
||||||
|
background: rgba(74, 222, 128, 0.1);
|
||||||
|
border-color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation-message.warning {
|
||||||
|
background: rgba(251, 191, 36, 0.1);
|
||||||
|
border-color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation-message strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation-message p {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-btn {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
background: #fbbf24;
|
||||||
|
color: #1a1a2e;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-btn:hover {
|
||||||
|
background: #f59e0b;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(251, 191, 36, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.indicator {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light {
|
||||||
|
width: 0.75rem;
|
||||||
|
height: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal-like on mobile */
|
||||||
|
.panel-backdrop {
|
||||||
|
display: flex;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 2000;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-panel {
|
||||||
|
position: relative;
|
||||||
|
top: auto;
|
||||||
|
right: auto;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
max-height: 85vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h3 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
function renderBar(value: number | null): string {
|
function renderBar(value: number | null): string {
|
||||||
// Slider-style visualization with brackets and value number
|
// Slider-style visualization with brackets and value number
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
return '[----X----]';
|
return '[----+----]';
|
||||||
}
|
}
|
||||||
// Value is 1-9, show the number at its position along the slider
|
// Value is 1-9, show the number at its position along the slider
|
||||||
const bars = [
|
const bars = [
|
||||||
|
|||||||
1
bicorder-app/src/vite-env.d.ts
vendored
1
bicorder-app/src/vite-env.d.ts
vendored
@@ -2,6 +2,7 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
declare const __BICORDER_DATA__: any
|
declare const __BICORDER_DATA__: any
|
||||||
|
declare const __BICORDER_MODEL__: any
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_APP_TITLE: string
|
readonly VITE_APP_TITLE: string
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ const bicorderData = JSON.parse(
|
|||||||
fs.readFileSync(path.resolve(__dirname, '../bicorder.json'), 'utf-8')
|
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({
|
export default defineConfig({
|
||||||
base: './',
|
base: './',
|
||||||
plugins: [
|
plugins: [
|
||||||
@@ -62,6 +67,7 @@ export default defineConfig({
|
|||||||
})
|
})
|
||||||
],
|
],
|
||||||
define: {
|
define: {
|
||||||
'__BICORDER_DATA__': JSON.stringify(bicorderData)
|
'__BICORDER_DATA__': JSON.stringify(bicorderData),
|
||||||
|
'__BICORDER_MODEL__': JSON.stringify(bicorderModel)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -264,6 +264,16 @@
|
|||||||
"value": null,
|
"value": null,
|
||||||
"notes": 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": "not useful",
|
||||||
"term_left_description": "The bicorder was not useful or relevant for analyzing this protocol",
|
"term_left_description": "The bicorder was not useful or relevant for analyzing this protocol",
|
||||||
|
|||||||
Reference in New Issue
Block a user