feat: remove formal/informal LDA analysis from the bicorder (v1.3.0)

The two-family cluster classification is a research finding, not a
diagnostic; embedding it in the tool caused recurring bugs:

- ascii_bicorder.py had an inverted LDA sign (institutional mapped to 9,
  not 1) and a stale term check ('bureaucratic') that silently disabled
  the calculation after the Feb 2026 rename — output was always null
- The web app and Python script had divergent sign conventions and
  divergent version-mismatch behavior (skip vs. continue with stale model)

Changes:
- bicorder.json: drop the formal/informal analysis gradient; version 1.3.0
- ascii_bicorder.py: remove all LDA/model machinery; keep hardness and
  polarization as the only automated analyses
- App.svelte: remove classifier import, model constant, LDA calculation,
  and form-recommendation reactive block; dispatch automated analyses by
  term_left instead of array index
- Delete bicorder-classifier.ts and FormRecommendation.svelte (the latter
  was imported but never rendered)
- AnalysisTransitionBanner: remove recommendation alert and prop; fix
  hardcoded index checks that referenced the removed gradient
- vite.config.ts / vite-env.d.ts: stop loading bicorder_model.json

The cluster classifier lives on as research in analysis/ (scripts and
model untouched there). bicorder.txt regenerated.
This commit is contained in:
Protocolbot committed 2026-09-23 07:58:11 -06:00
1 parent fb3bebcea0
commit 8c40ca076b
9 files changed
+22 -995

No files matched your search

+4 -106
View File
@@ -6,93 +6,9 @@ Generate bicorder.txt from bicorder.json
import json
import argparse
import sys
import os
from pathlib import Path
# Simple version-based approach
#
# The model includes a 'bicorder_version' field indicating which version of
# bicorder.json it was trained on. The code checks that versions match before
# calculating. This ensures the gradient structure is compatible.
#
# When bicorder.json changes (gradients added/removed/reordered), update the
# version number and retrain the model.
def load_classifier_model():
"""Load the LDA model from bicorder_model.json"""
# Try to find the model file
script_dir = Path(__file__).parent
model_paths = [
script_dir / 'analysis' / 'bicorder_model.json',
script_dir / 'bicorder_model.json',
Path('analysis/bicorder_model.json'),
Path('bicorder_model.json'),
]
for path in model_paths:
if path.exists():
with open(path, 'r') as f:
return json.load(f)
return None
def calculate_lda_score(values_array, model):
"""
Calculate LDA score from an array of values using the model.
Args:
values_array: list of 23 values (1-9) in the order expected by the model
model: loaded classifier model
Returns:
LDA score (float), or None if insufficient data
"""
if model is None:
return None
if len(values_array) != len(model['dimensions']):
return None
# Standardize using model scaler
mean = model['scaler']['mean']
scale = model['scaler']['scale']
scaled = [(values_array[i] - mean[i]) / scale[i] for i in range(len(values_array))]
# Calculate LDA score: coef · x + intercept
coef = model['lda']['coefficients']
intercept = model['lda']['intercept']
# Dot product
lda_score = sum(coef[i] * scaled[i] for i in range(len(scaled))) + intercept
return lda_score
def lda_score_to_scale(lda_score):
"""
Convert LDA score to 1-9 scale.
LDA scores typically range from -4 to +4 (8 range)
Target scale is 1 to 9 (8 range)
Formula: value = 5 + (lda_score * 4/3)
- LDA -3 or less → 1 (bureaucratic)
- LDA 0 → 5 (boundary)
- LDA +3 or more → 9 (relational)
"""
if lda_score is None:
return None
# Scale: value = 5 + (lda_score * 1.33)
value = 5 + (lda_score * 4.0 / 3.0)
# Clamp to 1-9 range and round
value = max(1, min(9, value))
return round(value)
def calculate_hardness(diagnostic_values):
"""Calculate hardness/softness (mean of all diagnostic values)"""
if not diagnostic_values:
@@ -133,34 +49,24 @@ def calculate_automated_analysis(json_data):
"""
Calculate values for automated analysis fields.
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
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):
@@ -173,14 +79,6 @@ def calculate_automated_analysis(json_data):
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):
+11 -125
View File
@@ -6,18 +6,12 @@
import AnalysisDisplay from './components/AnalysisDisplay.svelte';
import ExportControls from './components/ExportControls.svelte';
import HelpModal from './components/HelpModal.svelte';
import FormRecommendation from './components/FormRecommendation.svelte';
import AnalysisTransitionBanner from './components/AnalysisTransitionBanner.svelte';
import HamburgerMenu from './components/HamburgerMenu.svelte';
import Landing from './components/Landing.svelte';
import { BicorderClassifier } from './bicorder-classifier';
// Load bicorder data and model from build-time constants
let data: BicorderState = JSON.parse(JSON.stringify(__BICORDER_DATA__));
const model = __BICORDER_MODEL__;
// Initialize classifier
const classifier = new BicorderClassifier(model, data.version);
// Initialize timestamp if not set
if (!data.metadata.timestamp) {
@@ -87,10 +81,12 @@
});
// Analysis screens (shown in both shortform and longform)
// Show the useful gradient first (index 3), then the others
const analysisOrder = [3, 0, 1, 2]; // useful, hardness, polarization, formal/informal
// Show the useful gradient first, then the automated ones
const analysisOrder = [2, 0, 1]; // useful, hardness, polarization
analysisOrder.forEach((index) => {
if (index < data.analysis.length) {
screens.push({ type: 'analysis', index, gradient: data.analysis[index] });
}
});
// Export screen
@@ -180,57 +176,6 @@
.flatMap(set => set.gradients)
.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
onMount(() => {
const saved = localStorage.getItem('bicorder-state');
@@ -328,79 +273,21 @@
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
// 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) => {
if (item.automated) {
if (index === 0) {
// Hardness/Softness
data.analysis[0].value = calculateHardness();
} else if (index === 1) {
// Polarized/Centrist
data.analysis[1].value = calculatePolarization();
} else if (index === 2) {
// Formal/Informal (LDA classifier)
data.analysis[2].value = calculateFormalInformal();
if (item.term_left === 'hardness') {
item.value = calculateHardness();
} else if (item.term_left === 'polarized') {
item.value = calculatePolarization();
}
}
});
}
function handleMetadataUpdate(event: CustomEvent) {
// Properly trigger reactivity for nested metadata changes
data = {
@@ -604,7 +491,6 @@
{#if isFirstAnalysisScreen}
<AnalysisTransitionBanner
recommendation={formRecommendation}
isShortForm={data.metadata.shortform}
completedGradients={completedGradientsCount}
allAnalysisGradients={data.analysis}
-272
View File
@@ -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 type { AnalysisGradient } from '../types';
export let recommendation: any = null;
export let isShortForm: boolean;
export let completedGradients: number;
export let allAnalysisGradients: AnalysisGradient[];
@@ -15,8 +14,6 @@
updateAnalysisNotes: { index: number; notes: string };
}>();
$: hasRecommendation = recommendation?.recommendedForm === 'long';
let showAllAnalysis = false;
function handleSwitchToLongForm() {
@@ -42,32 +39,6 @@
</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 -->
<div class="action-buttons">
<button class="action-btn view-analysis-btn" on:click={toggleAllAnalysis}>
@@ -88,7 +59,6 @@
<div class="all-analysis-section">
<div class="analysis-header">Analysis Gradients</div>
{#each allAnalysisGradients as gradient, index}
{#if index !== 3}
<div class="analysis-item">
<AnalysisDisplay
{gradient}
@@ -97,7 +67,6 @@
on:notes={(e) => dispatch('updateAnalysisNotes', { index, notes: e.detail })}
/>
</div>
{/if}
{/each}
</div>
{/if}
@@ -199,39 +168,11 @@
opacity: 0.7;
}
.recommendation-alert {
margin-top: 1rem;
padding: 1rem;
background: rgba(251, 191, 36, 0.1);
border: 2px solid #fbbf24;
border-radius: 4px;
}
.alert-header {
.banner-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.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;
gap: 1rem;
margin-bottom: 0.5rem;
}
.action-buttons {
@@ -343,10 +284,6 @@
font-size: 0.8rem;
}
.alert-body {
padding-left: 1rem;
}
.action-btn {
font-size: 0.9rem;
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>
-1
View File
@@ -2,7 +2,6 @@
/// <reference types="vite/client" />
declare const __BICORDER_DATA__: any
declare const __BICORDER_MODEL__: any
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string
+1 -7
View File
@@ -9,11 +9,6 @@ const bicorderData = JSON.parse(
fs.readFileSync(path.resolve(__dirname, '../bicorder.json'), 'utf-8')
)
// Read bicorder_model.json at build time
const bicorderModel = JSON.parse(
fs.readFileSync(path.resolve(__dirname, '../analysis/bicorder_model.json'), 'utf-8')
)
export default defineConfig({
base: './',
plugins: [
@@ -61,7 +56,6 @@ export default defineConfig({
})
],
define: {
'__BICORDER_DATA__': JSON.stringify(bicorderData),
'__BICORDER_MODEL__': JSON.stringify(bicorderModel)
'__BICORDER_DATA__': JSON.stringify(bicorderData)
}
})
+2 -15
View File
@@ -1,11 +1,10 @@
{
"name": "Protocol Bicorder",
"schema": "bicorder.schema.json",
"version": "1.2.6",
"version": "1.3.0",
"description": "A diagnostic tool for the study of protocols",
"author": "Nathan Schneider",
"date_modified": "2026-02-21",
"date_modified": "2026-09-22",
"metadata": {
"protocol": null,
"description": null,
@@ -14,7 +13,6 @@
"timestamp": null,
"shortform": true
},
"diagnostic": [
{
"set_name": "Design",
@@ -242,7 +240,6 @@
]
}
],
"analysis": [
{
"term_left": "hardness",
@@ -264,16 +261,6 @@
"value": 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_description": "The bicorder was not useful or relevant for analyzing this protocol",
+1 -4
View File
@@ -39,7 +39,6 @@ self-enforcing < [---------] > enforced
ANALYSIS
hardness < [---------] > softness
polarized < [---------] > centrist
formal < [---------] > informal
not useful < [---------] > very useful
GLOSSARY
@@ -64,12 +63,10 @@ self-enforcing < [---------] > enforced
| explicit | Design is stated explicitly somewhere that is accessible to participants |
| exposed | Weak boundaries and vulnerable to external influence |
| 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 |
| implicit | Design is not stated explicitly and is learned by use |
| inclusion | Reduces barriers and includes diverse participants |
| informal | Exhibits relational characteristics with distributed coordination embedded in culture |
| institutional | Design occurs through processes controlled by powerful institutions |
| institutional | Design occurs through processes controlled by particular institutions |
| interpretive | Ambiguous design, allowing participants a wide range of interpretation |
| 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 |