Compare commits
3
Commits
c571bf1c01
...
e7d6465ceb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7d6465ceb | ||
|
|
8c40ca076b | ||
|
|
fb3bebcea0 |
No files matched your search
+66
-371
@@ -1,386 +1,81 @@
|
|||||||
# Bicorder Classifier Integration Guide
|
# Bicorder Classifier — Research Notes
|
||||||
|
|
||||||
|
> **Status: removed from the tool (v1.3.0).** The formal/informal (bureaucratic↔relational)
|
||||||
|
> LDA analysis was removed from the bicorder itself in v1.3.0. The cluster
|
||||||
|
> classification survives as **research only** — the scripts in this directory
|
||||||
|
> can still train and apply the model to datasets, but the web app and
|
||||||
|
> `ascii_bicorder.py` no longer consume it. This document is retained as a
|
||||||
|
> historical record of how the integration worked and how to reproduce the
|
||||||
|
> research analysis.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
This guide explains how to integrate the cluster classification system into the Bicorder web application to provide:
|
The analysis directory contains a cluster classification system that was
|
||||||
|
previously integrated into the Bicorder web application to provide:
|
||||||
|
|
||||||
1. **Real-time cluster prediction** as users fill out diagnostics
|
1. **Real-time cluster prediction** as users filled out diagnostics
|
||||||
2. **Smart form selection** (short vs. long form based on classification confidence)
|
2. **Smart form selection** (short vs. long form based on classification confidence)
|
||||||
3. **Visual feedback** showing protocol family positioning
|
3. **Visual feedback** showing protocol family positioning
|
||||||
|
|
||||||
## Design Philosophy
|
## Original Design Philosophy
|
||||||
|
|
||||||
**Version-based compatibility**: The model includes a `bicorder_version` field. The classifier checks that versions match. When bicorder.json structure changes:
|
**Version-based compatibility**: The model included a `bicorder_version` field.
|
||||||
1. Increment the version number in bicorder.json
|
The classifier checked that versions matched. When bicorder.json structure changed:
|
||||||
2. Retrain the model with `python3 scripts/export_model_for_js.py data/synthetic_20251116/readings.csv`
|
1. The version number in bicorder.json was incremented
|
||||||
3. The new model will have the updated version
|
2. The model was retrained with `python3 scripts/export_model_for_js.py data/synthetic_20251116/readings.csv`
|
||||||
|
3. The new model had the updated version
|
||||||
|
|
||||||
This ensures the web app and model stay in sync without complex backward compatibility.
|
## Files (research-only now)
|
||||||
|
|
||||||
## Files
|
- `bicorder_model.json` - Trained model parameters (~5KB), trained on the synthetic dataset (bicorder v1.2.6 structure — **stale** relative to v1.3.0; retrain before applying to new readings)
|
||||||
|
- `scripts/bicorder_classifier.py` - Python classifier (used by `classify_readings.py`)
|
||||||
|
- `scripts/export_model_for_js.py` - Retrain and export the model to JSON
|
||||||
|
- `scripts/classify_readings.py` - Apply the classifier to a readings CSV
|
||||||
|
|
||||||
- `bicorder_model.json` - Trained model parameters (~5KB); read by `bicorder-app` at build time from `../analysis/bicorder_model.json`
|
## Reproducing the research analysis
|
||||||
- `bicorder-app/src/bicorder-classifier.ts` - TypeScript classifier implementation (lives in the app, not here)
|
|
||||||
|
|
||||||
The model is the only artifact produced by this analysis directory that the app consumes. Regenerate it after re-running analysis on the synthetic dataset:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 scripts/export_model_for_js.py data/synthetic_20251116/readings.csv
|
# Retrain the model on a (new) synthetic dataset
|
||||||
|
python3 scripts/export_model_for_js.py data/<dataset>/readings.csv
|
||||||
|
|
||||||
|
# Classify a dataset's readings
|
||||||
|
python3 scripts/classify_readings.py data/<dataset>/readings.csv --training data/<dataset>/readings.csv
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
The classifier predicts which of two protocol families a reading belongs to:
|
||||||
|
- **Cluster 1: Relational/Cultural** — community-based, emergent, voluntary protocols
|
||||||
### Basic Usage
|
- **Cluster 2: Institutional/Bureaucratic** — formal, top-down, externally enforced protocols
|
||||||
|
|
||||||
```javascript
|
See `analysis/README.md` for the full multivariate analysis these clusters came from.
|
||||||
import { loadClassifier } from './lib/bicorder-classifier.js';
|
|
||||||
|
## Historical integration patterns
|
||||||
// Load model once at app startup
|
|
||||||
const classifier = await loadClassifier('/bicorder_model.json');
|
The removed web-app integration supported progressive classification display,
|
||||||
|
smart form selection (suggesting the long form when classification confidence
|
||||||
// As user fills in diagnostic form
|
was low), short-form optimization around the most discriminative dimensions,
|
||||||
function onDimensionChange(dimensionName, value) {
|
and readiness checks. The Python classifier API remains:
|
||||||
const currentRatings = getCurrentFormValues(); // Your form state
|
|
||||||
|
- `predict(ratings, options)` → cluster, clusterName, confidence, completeness, recommendedForm (detailed mode adds ldaScore, distanceToBoundary, dimension counts)
|
||||||
const result = classifier.predict(currentRatings);
|
- `explain_classification(ratings)` → human-readable explanation
|
||||||
|
- `get_key_dimensions()` → the shortform/key dimensions from bicorder.json
|
||||||
console.log(`Cluster: ${result.clusterName}`);
|
- `assess_short_form_readiness(ratings)` (TS only, removed) — the Python `recommended_form` field remains
|
||||||
console.log(`Confidence: ${result.confidence}%`);
|
|
||||||
console.log(`Recommend: ${result.recommendedForm} form`);
|
The shortform gradients themselves are defined in `bicorder.json`
|
||||||
|
(`shortform: true`), derived from the original feature-importance analysis —
|
||||||
updateUI(result);
|
that part of the research lives on in the tool.
|
||||||
}
|
|
||||||
```
|
## Why it was removed
|
||||||
|
|
||||||
## Integration Patterns
|
- The LDA sign convention was inverted in `ascii_bicorder.py` (never caught
|
||||||
|
there because a term-rename also silently disabled the calculation), while
|
||||||
### Pattern 1: Progressive Classification Display
|
the web app had been separately fixed — two divergent implementations.
|
||||||
|
- Compressing a two-family classification into a 1–9 gradient was semantically
|
||||||
Show classification results as the user fills out the form:
|
awkward and produced recurring bugs (see commit `fd556d9`).
|
||||||
|
- The version-mismatch handling differed between implementations (Python
|
||||||
```javascript
|
skipped; TypeScript continued with a stale model).
|
||||||
// React/Svelte component example
|
- The two-families finding is a research result, not a diagnostic — it belongs
|
||||||
function DiagnosticForm() {
|
in analysis, not in the instrument itself.
|
||||||
const [ratings, setRatings] = useState({});
|
|
||||||
const [classification, setClassification] = useState(null);
|
The form-recommendation feature (suggesting long form when classification
|
||||||
|
confidence was low) was also removed. Shortform/longform selection is now
|
||||||
useEffect(() => {
|
entirely the analyst's choice.
|
||||||
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 (run from within `bicorder-app`):
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { BicorderClassifier } from './bicorder-classifier';
|
|
||||||
import modelData from '../../analysis/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 `bicorder-app/src/bicorder-classifier.ts` for the live implementation, and `bicorder-app/src/App.svelte` for how it's wired into the form.
|
|
||||||
+7
-2
@@ -416,9 +416,14 @@ 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
|
## Integration with Bicorder Tool (historical)
|
||||||
|
|
||||||
The cluster analysis findings have been integrated into the bicorder system as an automated analysis gradient:
|
> **Update (v1.3.0):** The bureaucratic↔relational (formal/informal) LDA analysis
|
||||||
|
> has been **removed from the bicorder itself**. The cluster classification lives
|
||||||
|
> on as research in this directory only — see `INTEGRATION_GUIDE.md` for how to
|
||||||
|
> reproduce it and why it was removed from the tool.
|
||||||
|
|
||||||
|
The cluster analysis findings were previously 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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import argparse
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
from bicorder_query import get_row_values, load_bicorder_config, extract_gradients
|
||||||
|
|
||||||
|
|
||||||
def count_csv_rows(csv_path):
|
def count_csv_rows(csv_path):
|
||||||
"""Count the number of data rows in a CSV file."""
|
"""Count the number of data rows in a CSV file."""
|
||||||
@@ -44,13 +47,15 @@ def run_bicorder_analyze(input_csv, output_csv, bicorder_path, analyst=None, sta
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def query_gradients(output_csv, row_num, bicorder_path, model=None):
|
def query_gradients(output_csv, row_num, bicorder_path, model=None, resume=False):
|
||||||
"""Query all gradients for a protocol row."""
|
"""Query all gradients for a protocol row."""
|
||||||
cmd = ['python3', str(Path(__file__).parent / 'bicorder_query.py'), output_csv, str(row_num),
|
cmd = ['python3', str(Path(__file__).parent / 'bicorder_query.py'), output_csv, str(row_num),
|
||||||
'-b', bicorder_path]
|
'-b', bicorder_path]
|
||||||
|
|
||||||
if model:
|
if model:
|
||||||
cmd.extend(['-m', model])
|
cmd.extend(['-m', model])
|
||||||
|
if resume:
|
||||||
|
cmd.extend(['--resume'])
|
||||||
|
|
||||||
print(f"Starting gradient queries...")
|
print(f"Starting gradient queries...")
|
||||||
|
|
||||||
@@ -64,14 +69,28 @@ def query_gradients(output_csv, row_num, bicorder_path, model=None):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def process_protocol_row(input_csv, output_csv, row_num, total_rows, bicorder_path, model=None):
|
def process_protocol_row(input_csv, output_csv, row_num, total_rows, bicorder_path, model=None, resume=False):
|
||||||
"""Process a single protocol row through the complete workflow."""
|
"""Process a single protocol row through the complete workflow."""
|
||||||
print(f"\n{'='*60}")
|
print(f"\n{'='*60}")
|
||||||
print(f"Row {row_num}/{total_rows}")
|
print(f"Row {row_num}/{total_rows}")
|
||||||
print(f"{'='*60}")
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
# With resume: skip rows where all gradient columns already have values
|
||||||
|
if resume:
|
||||||
|
row_values = get_row_values(output_csv, row_num)
|
||||||
|
if row_values:
|
||||||
|
bicorder_data = load_bicorder_config(bicorder_path)
|
||||||
|
gradients = extract_gradients(bicorder_data)
|
||||||
|
gradient_cols = [g['column_name'] for g in gradients]
|
||||||
|
filled = sum(1 for c in gradient_cols if row_values.get(c, '').strip())
|
||||||
|
if filled == len(gradient_cols):
|
||||||
|
print(f"[SKIP] Row {row_num} complete ({filled}/{len(gradient_cols)} values) — resuming")
|
||||||
|
return True
|
||||||
|
elif filled > 0:
|
||||||
|
print(f"[RESUME] Row {row_num} partially complete ({filled}/{len(gradient_cols)} values)")
|
||||||
|
|
||||||
# Query all gradients (each gradient gets a new chat)
|
# Query all gradients (each gradient gets a new chat)
|
||||||
if not query_gradients(output_csv, row_num, bicorder_path, model):
|
if not query_gradients(output_csv, row_num, bicorder_path, model, resume):
|
||||||
print(f"[FAILED] Could not query gradients")
|
print(f"[FAILED] Could not query gradients")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -112,7 +131,7 @@ Example usage:
|
|||||||
parser.add_argument('--end', type=int,
|
parser.add_argument('--end', type=int,
|
||||||
help='End row number (1-indexed, default: all rows)')
|
help='End row number (1-indexed, default: all rows)')
|
||||||
parser.add_argument('--resume', action='store_true',
|
parser.add_argument('--resume', action='store_true',
|
||||||
help='Resume from existing output CSV (skip rows with values)')
|
help='Resume from existing output CSV (skip gradients that already have values)')
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -156,7 +175,7 @@ Example usage:
|
|||||||
|
|
||||||
for row_num in range(args.start, end_row + 1):
|
for row_num in range(args.start, end_row + 1):
|
||||||
if process_protocol_row(args.input_csv, args.output, row_num, end_row,
|
if process_protocol_row(args.input_csv, args.output, row_num, end_row,
|
||||||
args.bicorder, args.model):
|
args.bicorder, args.model, args.resume):
|
||||||
success_count += 1
|
success_count += 1
|
||||||
else:
|
else:
|
||||||
fail_count += 1
|
fail_count += 1
|
||||||
|
|||||||
@@ -54,6 +54,16 @@ def get_protocol_by_row(csv_path, row_number):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_row_values(csv_path, row_number):
|
||||||
|
"""Get all existing values for a row (1-indexed) as a dict of column -> value."""
|
||||||
|
with open(csv_path, 'r', encoding='utf-8') as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
for i, row in enumerate(reader, start=1):
|
||||||
|
if i == row_number:
|
||||||
|
return row
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def generate_gradient_prompt(protocol_descriptor, protocol_description, gradient):
|
def generate_gradient_prompt(protocol_descriptor, protocol_description, gradient):
|
||||||
"""Generate a prompt for a single gradient evaluation."""
|
"""Generate a prompt for a single gradient evaluation."""
|
||||||
return f"""Analyze this protocol: "{protocol_descriptor}"
|
return f"""Analyze this protocol: "{protocol_descriptor}"
|
||||||
@@ -153,6 +163,8 @@ Example usage:
|
|||||||
default='../bicorder.json',
|
default='../bicorder.json',
|
||||||
help='Path to bicorder.json (default: ../bicorder.json)')
|
help='Path to bicorder.json (default: ../bicorder.json)')
|
||||||
parser.add_argument('-m', '--model', help='LLM model to use')
|
parser.add_argument('-m', '--model', help='LLM model to use')
|
||||||
|
parser.add_argument('--resume', action='store_true',
|
||||||
|
help='Skip gradients that already have values in the CSV')
|
||||||
parser.add_argument('--dry-run', action='store_true',
|
parser.add_argument('--dry-run', action='store_true',
|
||||||
help='Show prompts without calling LLM or updating CSV')
|
help='Show prompts without calling LLM or updating CSV')
|
||||||
|
|
||||||
@@ -177,6 +189,15 @@ Example usage:
|
|||||||
bicorder_data = load_bicorder_config(args.bicorder)
|
bicorder_data = load_bicorder_config(args.bicorder)
|
||||||
gradients = extract_gradients(bicorder_data)
|
gradients = extract_gradients(bicorder_data)
|
||||||
|
|
||||||
|
# Load existing values for this row (for resume mode)
|
||||||
|
existing_values = get_row_values(args.csv_path, args.row_number) or {}
|
||||||
|
|
||||||
|
# Count existing values for reporting
|
||||||
|
if args.resume:
|
||||||
|
already_filled = sum(1 for g in gradients if existing_values.get(g['column_name'], '').strip())
|
||||||
|
if already_filled:
|
||||||
|
print(f"Resume: {already_filled}/{len(gradients)} gradients already have values")
|
||||||
|
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
print(f"DRY RUN: Row {args.row_number}, {len(gradients)} gradients")
|
print(f"DRY RUN: Row {args.row_number}, {len(gradients)} gradients")
|
||||||
print(f"Protocol: {protocol['descriptor']}\n")
|
print(f"Protocol: {protocol['descriptor']}\n")
|
||||||
@@ -188,6 +209,11 @@ Example usage:
|
|||||||
for i, gradient in enumerate(gradients, 1):
|
for i, gradient in enumerate(gradients, 1):
|
||||||
gradient_short = gradient['column_name'].replace('_', ' ')
|
gradient_short = gradient['column_name'].replace('_', ' ')
|
||||||
|
|
||||||
|
# In resume mode, skip gradients that already have a value
|
||||||
|
if args.resume and existing_values.get(gradient['column_name'], '').strip():
|
||||||
|
print(f"[{i}/{len(gradients)}] {gradient_short}: SKIP (already has value)")
|
||||||
|
continue
|
||||||
|
|
||||||
if not args.dry_run:
|
if not args.dry_run:
|
||||||
print(f"[{i}/{len(gradients)}] Querying: {gradient_short}...", flush=True)
|
print(f"[{i}/{len(gradients)}] Querying: {gradient_short}...", flush=True)
|
||||||
|
|
||||||
|
|||||||
+4
-106
@@ -6,93 +6,9 @@ Generate bicorder.txt from bicorder.json
|
|||||||
import json
|
import json
|
||||||
import argparse
|
import argparse
|
||||||
import sys
|
import sys
|
||||||
import os
|
|
||||||
from pathlib import Path
|
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):
|
def calculate_hardness(diagnostic_values):
|
||||||
"""Calculate hardness/softness (mean of all diagnostic values)"""
|
"""Calculate hardness/softness (mean of all diagnostic values)"""
|
||||||
if not diagnostic_values:
|
if not diagnostic_values:
|
||||||
@@ -133,34 +49,24 @@ def calculate_automated_analysis(json_data):
|
|||||||
"""
|
"""
|
||||||
Calculate values for automated analysis fields.
|
Calculate values for automated analysis fields.
|
||||||
Modifies json_data in place.
|
Modifies json_data in place.
|
||||||
|
|
||||||
|
Note: the formal/informal (LDA classifier) analysis was removed from the
|
||||||
|
bicorder in v1.3.0. The cluster classification lives on as research in
|
||||||
|
analysis/ (see scripts/bicorder_classifier.py).
|
||||||
"""
|
"""
|
||||||
# Collect all diagnostic values in order
|
# Collect all diagnostic values in order
|
||||||
diagnostic_values = []
|
diagnostic_values = []
|
||||||
values_array = []
|
|
||||||
|
|
||||||
for diagnostic_set in json_data.get("diagnostic", []):
|
for diagnostic_set in json_data.get("diagnostic", []):
|
||||||
for gradient in diagnostic_set.get("gradients", []):
|
for gradient in diagnostic_set.get("gradients", []):
|
||||||
value = gradient.get("value")
|
value = gradient.get("value")
|
||||||
if value is not None:
|
if value is not None:
|
||||||
diagnostic_values.append(value)
|
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
|
# Only calculate if we have diagnostic values
|
||||||
if not diagnostic_values:
|
if not diagnostic_values:
|
||||||
return
|
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
|
# Calculate each automated analysis field
|
||||||
for analysis_item in json_data.get("analysis", []):
|
for analysis_item in json_data.get("analysis", []):
|
||||||
if not analysis_item.get("automated", False):
|
if not analysis_item.get("automated", False):
|
||||||
@@ -173,14 +79,6 @@ def calculate_automated_analysis(json_data):
|
|||||||
analysis_item["value"] = calculate_hardness(diagnostic_values)
|
analysis_item["value"] = calculate_hardness(diagnostic_values)
|
||||||
elif term_left == "polarized":
|
elif term_left == "polarized":
|
||||||
analysis_item["value"] = calculate_polarization(diagnostic_values)
|
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):
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ A Svelte Progressive Web App (PWA) for carrying out protocol diagnostics as defi
|
|||||||
|
|
||||||
- **Single-page diagnostic tool** with ASCII-styled interface
|
- **Single-page diagnostic tool** with ASCII-styled interface
|
||||||
- **Touch-friendly controls** optimized for mobile devices
|
- **Touch-friendly controls** optimized for mobile devices
|
||||||
- **Shortform toggle** - switch between full (23 gradients) and short (10 gradients) versions
|
- **Shortform toggle** - switch between full (23 gradients) and short (9 gradients) versions
|
||||||
- **Tooltips** on all gradient terms (long-press on mobile, hover on desktop)
|
- **Tooltips** on all gradient terms (long-press on mobile, hover on desktop)
|
||||||
- **Editable metadata** fields with auto-generated timestamps
|
- **Editable metadata** fields with auto-generated timestamps
|
||||||
- **Auto-calculated analysis** section (hardness/softness, polarized/centrist)
|
- **Auto-calculated analysis** section (hardness/softness, polarized/centrist)
|
||||||
|
|||||||
+11
-125
@@ -6,18 +6,12 @@
|
|||||||
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 AnalysisTransitionBanner from './components/AnalysisTransitionBanner.svelte';
|
import AnalysisTransitionBanner from './components/AnalysisTransitionBanner.svelte';
|
||||||
import HamburgerMenu from './components/HamburgerMenu.svelte';
|
import HamburgerMenu from './components/HamburgerMenu.svelte';
|
||||||
import Landing from './components/Landing.svelte';
|
import Landing from './components/Landing.svelte';
|
||||||
import { BicorderClassifier } from './bicorder-classifier';
|
|
||||||
|
|
||||||
// Load bicorder data and model from build-time constants
|
// 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) {
|
||||||
@@ -87,10 +81,12 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Analysis screens (shown in both shortform and longform)
|
// Analysis screens (shown in both shortform and longform)
|
||||||
// Show the useful gradient first (index 3), then the others
|
// Show the useful gradient first, then the automated ones
|
||||||
const analysisOrder = [3, 0, 1, 2]; // useful, hardness, polarization, formal/informal
|
const analysisOrder = [2, 0, 1]; // useful, hardness, polarization
|
||||||
analysisOrder.forEach((index) => {
|
analysisOrder.forEach((index) => {
|
||||||
|
if (index < data.analysis.length) {
|
||||||
screens.push({ type: 'analysis', index, gradient: data.analysis[index] });
|
screens.push({ type: 'analysis', index, gradient: data.analysis[index] });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Export screen
|
// Export screen
|
||||||
@@ -180,57 +176,6 @@
|
|||||||
.flatMap(set => set.gradients)
|
.flatMap(set => set.gradients)
|
||||||
.filter(g => !data.metadata.shortform || g.shortform).length;
|
.filter(g => !data.metadata.shortform || g.shortform).length;
|
||||||
|
|
||||||
// Calculate form recommendation (shared by FormRecommendation and AnalysisTransitionBanner)
|
|
||||||
let formRecommendation: any = null;
|
|
||||||
let hasEnoughDataForRecommendation = false;
|
|
||||||
|
|
||||||
$: {
|
|
||||||
// Collect ratings from diagnostic data
|
|
||||||
const ratings: Record<string, number> = {};
|
|
||||||
let valueCount = 0;
|
|
||||||
let shortFormTotal = 0;
|
|
||||||
|
|
||||||
data.diagnostic.forEach((diagnosticSet) => {
|
|
||||||
const setName = diagnosticSet.set_name;
|
|
||||||
diagnosticSet.gradients.forEach((gradient) => {
|
|
||||||
// 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 calculate if at least half of shortform gradients are complete
|
|
||||||
const threshold = Math.ceil(shortFormTotal / 2);
|
|
||||||
hasEnoughDataForRecommendation = valueCount >= threshold;
|
|
||||||
|
|
||||||
if (hasEnoughDataForRecommendation && data.metadata.shortform) {
|
|
||||||
try {
|
|
||||||
const prediction = classifier.predict(ratings, { detailed: true });
|
|
||||||
const assessment = classifier.assessShortFormReadiness(ratings);
|
|
||||||
formRecommendation = {
|
|
||||||
...prediction,
|
|
||||||
...assessment,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error calculating form recommendation:', error);
|
|
||||||
formRecommendation = null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
formRecommendation = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load saved state from localStorage
|
// Load saved state from localStorage
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const saved = localStorage.getItem('bicorder-state');
|
const saved = localStorage.getItem('bicorder-state');
|
||||||
@@ -328,79 +273,21 @@
|
|||||||
return Math.round(Math.max(1, Math.min(9, polarizationScore)));
|
return Math.round(Math.max(1, Math.min(9, polarizationScore)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function ldaScoreToScale(ldaScore: number | null): number | null {
|
|
||||||
/**
|
|
||||||
* Convert LDA score to the analysis[2] "formal vs informal" 1-9 scale.
|
|
||||||
* LDA scores typically range from -4 to +4 (8 range); target is 1-9.
|
|
||||||
*
|
|
||||||
* The model's sign convention (see analysis/bicorder_model.json):
|
|
||||||
* positive LDA → cluster 2 = Institutional/Bureaucratic = "formal"
|
|
||||||
* negative LDA → cluster 1 = Relational/Cultural = "informal"
|
|
||||||
* bicorder.json defines this gradient as 1 = formal, 9 = informal, so a
|
|
||||||
* positive LDA score must map toward 1 (formal). The score is therefore
|
|
||||||
* subtracted, not added.
|
|
||||||
*
|
|
||||||
* Formula: value = 5 - (ldaScore * 4/3)
|
|
||||||
* - LDA +3 or more → 1 (formal / institutional / bureaucratic)
|
|
||||||
* - LDA 0 → 5 (boundary, characteristics of both families)
|
|
||||||
* - LDA -3 or less → 9 (informal / relational / cultural)
|
|
||||||
*/
|
|
||||||
if (ldaScore === null) return null;
|
|
||||||
|
|
||||||
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 calculateFormalInformal(): 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) {
|
|
||||||
// Dimension name must match the model's keys: 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 the 1-9 formal/informal scale
|
|
||||||
return ldaScoreToScale(result.ldaScore);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error calculating formal/informal score:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update automated analysis values reactively
|
// Update automated analysis values reactively
|
||||||
|
// Note: the formal/informal (LDA classifier) analysis was removed from the
|
||||||
|
// bicorder in v1.3.0. Cluster classification lives on as research in
|
||||||
|
// analysis/ (see scripts/bicorder_classifier.py).
|
||||||
$: {
|
$: {
|
||||||
data.analysis.forEach((item, index) => {
|
data.analysis.forEach((item, index) => {
|
||||||
if (item.automated) {
|
if (item.automated) {
|
||||||
if (index === 0) {
|
if (item.term_left === 'hardness') {
|
||||||
// Hardness/Softness
|
item.value = calculateHardness();
|
||||||
data.analysis[0].value = calculateHardness();
|
} else if (item.term_left === 'polarized') {
|
||||||
} else if (index === 1) {
|
item.value = calculatePolarization();
|
||||||
// Polarized/Centrist
|
|
||||||
data.analysis[1].value = calculatePolarization();
|
|
||||||
} else if (index === 2) {
|
|
||||||
// Formal/Informal (LDA classifier)
|
|
||||||
data.analysis[2].value = calculateFormalInformal();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMetadataUpdate(event: CustomEvent) {
|
function handleMetadataUpdate(event: CustomEvent) {
|
||||||
// Properly trigger reactivity for nested metadata changes
|
// Properly trigger reactivity for nested metadata changes
|
||||||
data = {
|
data = {
|
||||||
@@ -604,7 +491,6 @@
|
|||||||
|
|
||||||
{#if isFirstAnalysisScreen}
|
{#if isFirstAnalysisScreen}
|
||||||
<AnalysisTransitionBanner
|
<AnalysisTransitionBanner
|
||||||
recommendation={formRecommendation}
|
|
||||||
isShortForm={data.metadata.shortform}
|
isShortForm={data.metadata.shortform}
|
||||||
completedGradients={completedGradientsCount}
|
completedGradients={completedGradientsCount}
|
||||||
allAnalysisGradients={data.analysis}
|
allAnalysisGradients={data.analysis}
|
||||||
|
|||||||
@@ -1,272 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 multiple issues are present:
|
|
||||||
// 1. Low confidence (< 0.5)
|
|
||||||
// 2. Low completeness (< 50% of dimensions)
|
|
||||||
// 3. Near boundary (< 0.3 distance)
|
|
||||||
// Require at least 2 conditions to be true
|
|
||||||
const issues = [
|
|
||||||
adjustedConfidence < this.model.thresholds.confidence_low,
|
|
||||||
completeness < this.model.thresholds.completeness_low,
|
|
||||||
distanceToBoundary < this.model.thresholds.boundary_distance_low
|
|
||||||
];
|
|
||||||
const issueCount = issues.filter(Boolean).length;
|
|
||||||
const shouldUseLongForm = issueCount >= 2;
|
|
||||||
|
|
||||||
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]
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
import AnalysisDisplay from './AnalysisDisplay.svelte';
|
import AnalysisDisplay from './AnalysisDisplay.svelte';
|
||||||
import type { AnalysisGradient } from '../types';
|
import type { AnalysisGradient } from '../types';
|
||||||
|
|
||||||
export let recommendation: any = null;
|
|
||||||
export let isShortForm: boolean;
|
export let isShortForm: boolean;
|
||||||
export let completedGradients: number;
|
export let completedGradients: number;
|
||||||
export let allAnalysisGradients: AnalysisGradient[];
|
export let allAnalysisGradients: AnalysisGradient[];
|
||||||
@@ -15,8 +14,6 @@
|
|||||||
updateAnalysisNotes: { index: number; notes: string };
|
updateAnalysisNotes: { index: number; notes: string };
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
$: hasRecommendation = recommendation?.recommendedForm === 'long';
|
|
||||||
|
|
||||||
let showAllAnalysis = false;
|
let showAllAnalysis = false;
|
||||||
|
|
||||||
function handleSwitchToLongForm() {
|
function handleSwitchToLongForm() {
|
||||||
@@ -42,32 +39,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recommendation Alert (if applicable) -->
|
|
||||||
{#if isShortForm && hasRecommendation && recommendation}
|
|
||||||
<div class="recommendation-alert">
|
|
||||||
<div class="alert-header">
|
|
||||||
<span class="alert-icon">⚠</span>
|
|
||||||
<strong>Long Form Recommended</strong>
|
|
||||||
</div>
|
|
||||||
<div class="alert-body">
|
|
||||||
<p class="alert-message">
|
|
||||||
{#if recommendation.confidence < 60}
|
|
||||||
• Low classification confidence ({recommendation.confidence}%)<br>
|
|
||||||
{/if}
|
|
||||||
{#if recommendation.completeness < 50}
|
|
||||||
• Incomplete data ({recommendation.completeness}% 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 ({recommendation.coverage}% coverage)<br>
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons -->
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<button class="action-btn view-analysis-btn" on:click={toggleAllAnalysis}>
|
<button class="action-btn view-analysis-btn" on:click={toggleAllAnalysis}>
|
||||||
@@ -88,7 +59,6 @@
|
|||||||
<div class="all-analysis-section">
|
<div class="all-analysis-section">
|
||||||
<div class="analysis-header">Analysis Gradients</div>
|
<div class="analysis-header">Analysis Gradients</div>
|
||||||
{#each allAnalysisGradients as gradient, index}
|
{#each allAnalysisGradients as gradient, index}
|
||||||
{#if index !== 3}
|
|
||||||
<div class="analysis-item">
|
<div class="analysis-item">
|
||||||
<AnalysisDisplay
|
<AnalysisDisplay
|
||||||
{gradient}
|
{gradient}
|
||||||
@@ -97,7 +67,6 @@
|
|||||||
on:notes={(e) => dispatch('updateAnalysisNotes', { index, notes: e.detail })}
|
on:notes={(e) => dispatch('updateAnalysisNotes', { index, notes: e.detail })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -199,39 +168,11 @@
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recommendation-alert {
|
.banner-header {
|
||||||
margin-top: 1rem;
|
|
||||||
padding: 1rem;
|
|
||||||
background: rgba(251, 191, 36, 0.1);
|
|
||||||
border: 2px solid #fbbf24;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-header {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 1rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
|
||||||
|
|
||||||
.alert-icon {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
color: #fbbf24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-header strong {
|
|
||||||
font-size: 1rem;
|
|
||||||
color: #fbbf24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-body {
|
|
||||||
padding-left: 1.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-message {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-buttons {
|
.action-buttons {
|
||||||
@@ -343,10 +284,6 @@
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-body {
|
|
||||||
padding-left: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
padding: 0.6rem;
|
padding: 0.6rem;
|
||||||
|
|||||||
@@ -1,399 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { createEventDispatcher } from 'svelte';
|
|
||||||
|
|
||||||
export let recommendation: any = null;
|
|
||||||
export let hasEnoughData: boolean;
|
|
||||||
export let isShortForm: boolean;
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{
|
|
||||||
switchToLongForm: void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
let isExpanded = false;
|
|
||||||
|
|
||||||
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 & Restart →
|
|
||||||
</button>
|
|
||||||
<p class="note">Returns to the beginning. 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>
|
|
||||||
Vendored
-1
@@ -2,7 +2,6 @@
|
|||||||
/// <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,11 +9,6 @@ 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: [
|
||||||
@@ -61,7 +56,6 @@ export default defineConfig({
|
|||||||
})
|
})
|
||||||
],
|
],
|
||||||
define: {
|
define: {
|
||||||
'__BICORDER_DATA__': JSON.stringify(bicorderData),
|
'__BICORDER_DATA__': JSON.stringify(bicorderData)
|
||||||
'__BICORDER_MODEL__': JSON.stringify(bicorderModel)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
+2
-15
@@ -1,11 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "Protocol Bicorder",
|
"name": "Protocol Bicorder",
|
||||||
"schema": "bicorder.schema.json",
|
"schema": "bicorder.schema.json",
|
||||||
"version": "1.2.6",
|
"version": "1.3.0",
|
||||||
"description": "A diagnostic tool for the study of protocols",
|
"description": "A diagnostic tool for the study of protocols",
|
||||||
"author": "Nathan Schneider",
|
"author": "Nathan Schneider",
|
||||||
"date_modified": "2026-02-21",
|
"date_modified": "2026-09-22",
|
||||||
|
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"protocol": null,
|
"protocol": null,
|
||||||
"description": null,
|
"description": null,
|
||||||
@@ -14,7 +13,6 @@
|
|||||||
"timestamp": null,
|
"timestamp": null,
|
||||||
"shortform": true
|
"shortform": true
|
||||||
},
|
},
|
||||||
|
|
||||||
"diagnostic": [
|
"diagnostic": [
|
||||||
{
|
{
|
||||||
"set_name": "Design",
|
"set_name": "Design",
|
||||||
@@ -242,7 +240,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
||||||
"analysis": [
|
"analysis": [
|
||||||
{
|
{
|
||||||
"term_left": "hardness",
|
"term_left": "hardness",
|
||||||
@@ -264,16 +261,6 @@
|
|||||||
"value": null,
|
"value": null,
|
||||||
"notes": null
|
"notes": null
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"term_left": "formal",
|
|
||||||
"term_left_description": "Exhibits bureaucratic characteristics with centralized control and predictable enforcement",
|
|
||||||
"term_right": "informal",
|
|
||||||
"term_right_description": "Exhibits relational characteristics with distributed coordination embedded in culture",
|
|
||||||
"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 formal protocols and 9 represents strongly informal 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",
|
||||||
|
|||||||
+1
-4
@@ -39,7 +39,6 @@ self-enforcing < [---------] > enforced
|
|||||||
ANALYSIS
|
ANALYSIS
|
||||||
hardness < [---------] > softness
|
hardness < [---------] > softness
|
||||||
polarized < [---------] > centrist
|
polarized < [---------] > centrist
|
||||||
formal < [---------] > informal
|
|
||||||
not useful < [---------] > very useful
|
not useful < [---------] > very useful
|
||||||
|
|
||||||
GLOSSARY
|
GLOSSARY
|
||||||
@@ -64,12 +63,10 @@ self-enforcing < [---------] > enforced
|
|||||||
| explicit | Design is stated explicitly somewhere that is accessible to participants |
|
| explicit | Design is stated explicitly somewhere that is accessible to participants |
|
||||||
| exposed | Weak boundaries and vulnerable to external influence |
|
| exposed | Weak boundaries and vulnerable to external influence |
|
||||||
| flocking | Coordination occurs through centralized direction or direct mimicry |
|
| flocking | Coordination occurs through centralized direction or direct mimicry |
|
||||||
| formal | Exhibits bureaucratic characteristics with centralized control and predictable enforcement |
|
|
||||||
| hardness | The protocol tends toward properties characterized by hardness |
|
| hardness | The protocol tends toward properties characterized by hardness |
|
||||||
| implicit | Design is not stated explicitly and is learned by use |
|
| implicit | Design is not stated explicitly and is learned by use |
|
||||||
| inclusion | Reduces barriers and includes diverse participants |
|
| inclusion | Reduces barriers and includes diverse participants |
|
||||||
| informal | Exhibits relational characteristics with distributed coordination embedded in culture |
|
| institutional | Design occurs through processes controlled by particular institutions |
|
||||||
| institutional | Design occurs through processes controlled by powerful institutions |
|
|
||||||
| interpretive | Ambiguous design, allowing participants a wide range of interpretation |
|
| interpretive | Ambiguous design, allowing participants a wide range of interpretation |
|
||||||
| liberating | Enables participants to carry out desired activities with less work or thought |
|
| liberating | Enables participants to carry out desired activities with less work or thought |
|
||||||
| limited | Does not, on its own, adequately meet the needs and goals of participants |
|
| limited | Does not, on its own, adequately meet the needs and goals of participants |
|
||||||
|
|||||||
Reference in new issue
Block a user