certainty-units 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/examples/cu-report.yml +48 -0
- package/examples/github.yaml +15 -0
- package/examples/jira.yaml +23 -0
- package/examples/linear.yaml +25 -0
- package/examples/notion.yaml +28 -0
- package/package.json +35 -0
- package/src/adapters/github.js +96 -0
- package/src/adapters/jira.js +122 -0
- package/src/adapters/linear.js +130 -0
- package/src/adapters/notion.js +143 -0
- package/src/adapters/util.js +90 -0
- package/src/certainty.js +152 -0
- package/src/cli.js +224 -0
- package/src/config.js +75 -0
- package/src/history.js +55 -0
- package/src/report.js +200 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Linear adapter — maps Linear issues to CU items via GraphQL
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
applyMap, mergeFieldMap,
|
|
5
|
+
extractAcceptanceCriteria, validationFromLabels, tierFromLabels,
|
|
6
|
+
} from './util.js'
|
|
7
|
+
|
|
8
|
+
const LINEAR_API = 'https://api.linear.app/graphql'
|
|
9
|
+
|
|
10
|
+
const ISSUES_QUERY = `
|
|
11
|
+
query Issues($teamId: ID!, $after: String) {
|
|
12
|
+
issues(
|
|
13
|
+
filter: { team: { id: { eq: $teamId } } }
|
|
14
|
+
first: 100
|
|
15
|
+
after: $after
|
|
16
|
+
) {
|
|
17
|
+
pageInfo { hasNextPage endCursor }
|
|
18
|
+
nodes {
|
|
19
|
+
id
|
|
20
|
+
identifier
|
|
21
|
+
title
|
|
22
|
+
description
|
|
23
|
+
priority
|
|
24
|
+
estimate
|
|
25
|
+
state { name type }
|
|
26
|
+
assignee { name }
|
|
27
|
+
labels { nodes { name } }
|
|
28
|
+
comments { nodes { id } }
|
|
29
|
+
createdAt
|
|
30
|
+
updatedAt
|
|
31
|
+
url
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
`
|
|
36
|
+
|
|
37
|
+
async function graphql(apiKey, query, variables = {}) {
|
|
38
|
+
const res = await fetch(LINEAR_API, {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: {
|
|
41
|
+
'Content-Type': 'application/json',
|
|
42
|
+
Authorization: apiKey,
|
|
43
|
+
},
|
|
44
|
+
body: JSON.stringify({ query, variables }),
|
|
45
|
+
})
|
|
46
|
+
if (!res.ok) throw new Error(`Linear API ${res.status}: ${await res.text()}`)
|
|
47
|
+
const json = await res.json()
|
|
48
|
+
if (json.errors) throw new Error(`Linear GraphQL: ${json.errors.map(e => e.message).join(', ')}`)
|
|
49
|
+
return json.data
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function fetchAllIssues(apiKey, teamId) {
|
|
53
|
+
const issues = []
|
|
54
|
+
let after = null
|
|
55
|
+
do {
|
|
56
|
+
const data = await graphql(apiKey, ISSUES_QUERY, { teamId, after })
|
|
57
|
+
issues.push(...data.issues.nodes)
|
|
58
|
+
after = data.issues.pageInfo.hasNextPage ? data.issues.pageInfo.endCursor : null
|
|
59
|
+
} while (after)
|
|
60
|
+
return issues
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Default field mapping — overridable via certainty.config.yaml fieldMap
|
|
64
|
+
const DEFAULT_MAP = {
|
|
65
|
+
validation_status: {
|
|
66
|
+
field: 'state.type',
|
|
67
|
+
map: {
|
|
68
|
+
completed: 'validated',
|
|
69
|
+
started: 'assumed',
|
|
70
|
+
triage: 'needs_clarification',
|
|
71
|
+
backlog: 'unvalidated',
|
|
72
|
+
cancelled: 'unvalidated',
|
|
73
|
+
},
|
|
74
|
+
default: 'unvalidated',
|
|
75
|
+
},
|
|
76
|
+
workflow_status: {
|
|
77
|
+
field: 'state.type',
|
|
78
|
+
map: {
|
|
79
|
+
completed: 'done',
|
|
80
|
+
started: 'in_progress',
|
|
81
|
+
triage: 'todo',
|
|
82
|
+
backlog: 'todo',
|
|
83
|
+
cancelled: 'todo',
|
|
84
|
+
},
|
|
85
|
+
default: 'todo',
|
|
86
|
+
},
|
|
87
|
+
cu_tier: {
|
|
88
|
+
field: 'priority',
|
|
89
|
+
map: { 1: 'advanced', 2: 'intermediate', 3: 'basic', 4: 'basic' },
|
|
90
|
+
default: null,
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function fetchItems(config) {
|
|
95
|
+
const { apiKey, teamId, fieldMap: userFieldMap } = config
|
|
96
|
+
if (!apiKey) throw new Error('linear.apiKey is required')
|
|
97
|
+
if (!teamId) throw new Error('linear.teamId is required')
|
|
98
|
+
|
|
99
|
+
const issues = await fetchAllIssues(apiKey, teamId)
|
|
100
|
+
const fieldMap = mergeFieldMap(DEFAULT_MAP, userFieldMap)
|
|
101
|
+
|
|
102
|
+
return issues.map(issue => {
|
|
103
|
+
const labels = issue.labels?.nodes?.map(l => l.name) ?? []
|
|
104
|
+
const description = issue.description || ''
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
id: issue.id,
|
|
108
|
+
external_id: issue.identifier,
|
|
109
|
+
title: issue.title,
|
|
110
|
+
description,
|
|
111
|
+
url: issue.url,
|
|
112
|
+
// an explicit validation label beats inferring validation from workflow state
|
|
113
|
+
validation_status: validationFromLabels(labels) ?? applyMap(issue, fieldMap.validation_status),
|
|
114
|
+
workflow_status: applyMap(issue, fieldMap.workflow_status),
|
|
115
|
+
cu_tier: tierFromLabels(labels) ?? applyMap(issue, fieldMap.cu_tier),
|
|
116
|
+
cu_value: issue.estimate ?? 1,
|
|
117
|
+
evidence: description,
|
|
118
|
+
acceptance_criteria: extractAcceptanceCriteria(description),
|
|
119
|
+
novelty_rating: null,
|
|
120
|
+
complexity_rating: null,
|
|
121
|
+
dependency_rating: null,
|
|
122
|
+
citation_count: issue.comments?.nodes?.length ?? 0,
|
|
123
|
+
assignee: issue.assignee?.name ?? null,
|
|
124
|
+
labels,
|
|
125
|
+
created_at: issue.createdAt,
|
|
126
|
+
updated_at: issue.updatedAt,
|
|
127
|
+
source: 'linear',
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Notion adapter — maps a Notion database to CU items via Notion API
|
|
2
|
+
|
|
3
|
+
import { mergeFieldMap, validationFromLabels, tierFromLabels } from './util.js'
|
|
4
|
+
|
|
5
|
+
const NOTION_API = 'https://api.notion.com/v1'
|
|
6
|
+
const NOTION_VERSION = '2022-06-28'
|
|
7
|
+
|
|
8
|
+
async function notionFetch(apiKey, path, body = null) {
|
|
9
|
+
const res = await fetch(`${NOTION_API}${path}`, {
|
|
10
|
+
method: body ? 'POST' : 'GET',
|
|
11
|
+
headers: {
|
|
12
|
+
Authorization: `Bearer ${apiKey}`,
|
|
13
|
+
'Notion-Version': NOTION_VERSION,
|
|
14
|
+
'Content-Type': 'application/json',
|
|
15
|
+
},
|
|
16
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
17
|
+
})
|
|
18
|
+
if (!res.ok) throw new Error(`Notion API ${res.status}: ${await res.text()}`)
|
|
19
|
+
return res.json()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function fetchAllPages(apiKey, databaseId) {
|
|
23
|
+
const pages = []
|
|
24
|
+
let cursor = null
|
|
25
|
+
|
|
26
|
+
do {
|
|
27
|
+
const body = { page_size: 100 }
|
|
28
|
+
if (cursor) body.start_cursor = cursor
|
|
29
|
+
const data = await notionFetch(apiKey, `/databases/${databaseId}/query`, body)
|
|
30
|
+
pages.push(...data.results)
|
|
31
|
+
cursor = data.has_more ? data.next_cursor : null
|
|
32
|
+
} while (cursor)
|
|
33
|
+
|
|
34
|
+
return pages
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Extract plain text from a Notion rich_text array
|
|
38
|
+
function richText(arr) {
|
|
39
|
+
return arr?.map(r => r.plain_text).join('') ?? ''
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Extract value from a Notion property by type
|
|
43
|
+
function extractProp(prop) {
|
|
44
|
+
if (!prop) return null
|
|
45
|
+
switch (prop.type) {
|
|
46
|
+
case 'title': return richText(prop.title)
|
|
47
|
+
case 'rich_text': return richText(prop.rich_text)
|
|
48
|
+
case 'select': return prop.select?.name ?? null
|
|
49
|
+
case 'status': return prop.status?.name ?? null
|
|
50
|
+
case 'multi_select': return prop.multi_select?.map(s => s.name) ?? []
|
|
51
|
+
case 'number': return prop.number
|
|
52
|
+
case 'checkbox': return prop.checkbox
|
|
53
|
+
case 'date': return prop.date?.start ?? null
|
|
54
|
+
case 'people': return prop.people?.map(p => p.name).join(', ') ?? null
|
|
55
|
+
case 'url': return prop.url
|
|
56
|
+
default: return null
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Default field map — teams override column names via config
|
|
61
|
+
const DEFAULT_MAP = {
|
|
62
|
+
validation_status: {
|
|
63
|
+
field: 'Status', // Notion column name
|
|
64
|
+
map: {
|
|
65
|
+
Done: 'validated',
|
|
66
|
+
'In Progress': 'assumed',
|
|
67
|
+
'To Do': 'unvalidated',
|
|
68
|
+
Backlog: 'unvalidated',
|
|
69
|
+
},
|
|
70
|
+
default: 'unvalidated',
|
|
71
|
+
},
|
|
72
|
+
workflow_status: {
|
|
73
|
+
field: 'Status',
|
|
74
|
+
map: {
|
|
75
|
+
Done: 'done',
|
|
76
|
+
'In Progress': 'in_progress',
|
|
77
|
+
'To Do': 'todo',
|
|
78
|
+
Backlog: 'todo',
|
|
79
|
+
},
|
|
80
|
+
default: 'todo',
|
|
81
|
+
},
|
|
82
|
+
cu_tier: {
|
|
83
|
+
field: 'Priority',
|
|
84
|
+
map: {
|
|
85
|
+
High: 'advanced',
|
|
86
|
+
Medium: 'intermediate',
|
|
87
|
+
Low: 'basic',
|
|
88
|
+
},
|
|
89
|
+
default: null,
|
|
90
|
+
},
|
|
91
|
+
title_field: { field: 'Name' },
|
|
92
|
+
assignee_field: { field: 'Assignee' },
|
|
93
|
+
estimate_field: { field: 'Estimate' },
|
|
94
|
+
labels_field: { field: 'Tags' }, // multi_select
|
|
95
|
+
description_field: { field: 'Description' }, // rich_text
|
|
96
|
+
acceptance_field: { field: 'Acceptance Criteria' }, // rich_text
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function fetchItems(config) {
|
|
100
|
+
const { apiKey, databaseId } = config
|
|
101
|
+
if (!apiKey) throw new Error('notion.apiKey is required')
|
|
102
|
+
if (!databaseId) throw new Error('notion.databaseId is required')
|
|
103
|
+
|
|
104
|
+
const pages = await fetchAllPages(apiKey, databaseId)
|
|
105
|
+
const fm = mergeFieldMap(DEFAULT_MAP, config.fieldMap)
|
|
106
|
+
|
|
107
|
+
return pages.map(page => {
|
|
108
|
+
const props = page.properties
|
|
109
|
+
const statusRaw = extractProp(props[fm.validation_status.field])
|
|
110
|
+
const labelsRaw = extractProp(props[fm.labels_field?.field])
|
|
111
|
+
const labels = Array.isArray(labelsRaw) ? labelsRaw : []
|
|
112
|
+
const description = extractProp(props[fm.description_field?.field]) ?? ''
|
|
113
|
+
const acceptance = extractProp(props[fm.acceptance_field?.field])
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
id: page.id,
|
|
117
|
+
external_id: page.id.slice(0, 8),
|
|
118
|
+
title: extractProp(props[fm.title_field.field]) ?? 'Untitled',
|
|
119
|
+
description,
|
|
120
|
+
url: page.url,
|
|
121
|
+
// an explicit validation tag beats inferring validation from workflow state
|
|
122
|
+
validation_status: validationFromLabels(labels)
|
|
123
|
+
?? fm.validation_status.map[statusRaw] ?? fm.validation_status.default,
|
|
124
|
+
workflow_status: fm.workflow_status.map[statusRaw] ?? fm.workflow_status.default,
|
|
125
|
+
cu_tier: tierFromLabels(labels) ?? (() => {
|
|
126
|
+
const raw = extractProp(props[fm.cu_tier.field])
|
|
127
|
+
return fm.cu_tier.map[raw] ?? fm.cu_tier.default
|
|
128
|
+
})(),
|
|
129
|
+
cu_value: extractProp(props[fm.estimate_field?.field]) ?? 1,
|
|
130
|
+
evidence: description,
|
|
131
|
+
acceptance_criteria: typeof acceptance === 'string' && acceptance.trim() ? acceptance : null,
|
|
132
|
+
novelty_rating: null,
|
|
133
|
+
complexity_rating: null,
|
|
134
|
+
dependency_rating: null,
|
|
135
|
+
citation_count: 0,
|
|
136
|
+
assignee: extractProp(props[fm.assignee_field?.field]),
|
|
137
|
+
labels,
|
|
138
|
+
created_at: page.created_time,
|
|
139
|
+
updated_at: page.last_edited_time,
|
|
140
|
+
source: 'notion',
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Shared adapter helpers: field mapping + signal extraction from real work items.
|
|
2
|
+
|
|
3
|
+
export function getField(obj, path) {
|
|
4
|
+
return path.split('.').reduce((o, k) => o?.[k], obj)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function applyMap(issue, fieldDef) {
|
|
8
|
+
const raw = getField(issue, fieldDef.field)
|
|
9
|
+
return fieldDef.map[raw] ?? fieldDef.default
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function mergeFieldMap(defaults, userMap = {}) {
|
|
13
|
+
const merged = structuredClone(defaults)
|
|
14
|
+
for (const [field, def] of Object.entries(userMap)) {
|
|
15
|
+
if (typeof def === 'object' && def !== null && def.map) {
|
|
16
|
+
merged[field] = {
|
|
17
|
+
field: def.field ?? merged[field]?.field,
|
|
18
|
+
map: { ...merged[field]?.map, ...def.map },
|
|
19
|
+
default: def.default ?? merged[field]?.default ?? null,
|
|
20
|
+
}
|
|
21
|
+
} else {
|
|
22
|
+
merged[field] = def
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return merged
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Pull an acceptance-criteria block out of a free-text description.
|
|
29
|
+
// Recognizes a heading containing "acceptance criteria" (markdown #, bold, or
|
|
30
|
+
// plain line ending in ":"), or falls back to markdown checklist items.
|
|
31
|
+
export function extractAcceptanceCriteria(text) {
|
|
32
|
+
if (!text?.trim()) return null
|
|
33
|
+
|
|
34
|
+
const lines = text.split('\n')
|
|
35
|
+
const headingRe = /^\s*(?:#{1,6}\s*|\*\*)?\s*acceptance criteria\b/i
|
|
36
|
+
|
|
37
|
+
const start = lines.findIndex(l => headingRe.test(l))
|
|
38
|
+
if (start !== -1) {
|
|
39
|
+
const block = []
|
|
40
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
41
|
+
// stop at the next heading
|
|
42
|
+
if (/^\s*#{1,6}\s/.test(lines[i]) || /^\s*\*\*[^*]+\*\*\s*$/.test(lines[i])) break
|
|
43
|
+
block.push(lines[i])
|
|
44
|
+
}
|
|
45
|
+
const body = block.join('\n').trim()
|
|
46
|
+
if (body) return body
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// fallback: markdown checklist anywhere in the description
|
|
50
|
+
const checklist = lines.filter(l => /^\s*[-*]\s*\[[ xX]\]/.test(l))
|
|
51
|
+
if (checklist.length) return checklist.join('\n')
|
|
52
|
+
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Labels are the cheapest place for a team to record validation explicitly.
|
|
57
|
+
// A label like "validated" beats inferring validation from workflow state.
|
|
58
|
+
const VALIDATION_LABELS = {
|
|
59
|
+
'validated': 'validated',
|
|
60
|
+
'validation:validated': 'validated',
|
|
61
|
+
'assumed': 'assumed',
|
|
62
|
+
'assumption': 'assumed',
|
|
63
|
+
'validation:assumed': 'assumed',
|
|
64
|
+
'needs-clarification': 'needs_clarification',
|
|
65
|
+
'needs clarification': 'needs_clarification',
|
|
66
|
+
'unvalidated': 'unvalidated',
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function validationFromLabels(labels = []) {
|
|
70
|
+
for (const label of labels) {
|
|
71
|
+
const hit = VALIDATION_LABELS[String(label).toLowerCase().trim()]
|
|
72
|
+
if (hit) return hit
|
|
73
|
+
}
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// cu:basic / cu:intermediate / cu:advanced labels set the tier explicitly.
|
|
78
|
+
const TIER_LABELS = {
|
|
79
|
+
'cu:basic': 'basic',
|
|
80
|
+
'cu:intermediate': 'intermediate',
|
|
81
|
+
'cu:advanced': 'advanced',
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function tierFromLabels(labels = []) {
|
|
85
|
+
for (const label of labels) {
|
|
86
|
+
const hit = TIER_LABELS[String(label).toLowerCase().trim()]
|
|
87
|
+
if (hit) return hit
|
|
88
|
+
}
|
|
89
|
+
return null
|
|
90
|
+
}
|
package/src/certainty.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Certainty Score engine — ported from Propozel (MIT)
|
|
2
|
+
// Items must conform to the CU shape: { validation_status, workflow_status,
|
|
3
|
+
// cu_tier, cu_value, evidence, acceptance_criteria, novelty_rating,
|
|
4
|
+
// complexity_rating, dependency_rating, certainty_score }
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_CU_CONFIG = {
|
|
7
|
+
signal_weight: 0.6,
|
|
8
|
+
signals: {
|
|
9
|
+
validation: 40,
|
|
10
|
+
workflow: 20,
|
|
11
|
+
citations: 15,
|
|
12
|
+
acceptance: 10,
|
|
13
|
+
tier: 10,
|
|
14
|
+
evidence: 5,
|
|
15
|
+
},
|
|
16
|
+
dimension_weight: 0.4,
|
|
17
|
+
dimensions: {
|
|
18
|
+
novelty: 0.45,
|
|
19
|
+
complexity: 0.35,
|
|
20
|
+
dependencies: 0.20,
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const VALIDATION_SCORE = {
|
|
25
|
+
validated: 40,
|
|
26
|
+
assumed: 10,
|
|
27
|
+
needs_clarification: 5,
|
|
28
|
+
unvalidated: 0,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const WORKFLOW_SCORE = {
|
|
32
|
+
done: 20,
|
|
33
|
+
in_progress: 10,
|
|
34
|
+
review: 15,
|
|
35
|
+
todo: 0,
|
|
36
|
+
blocked: 0,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Per-signal breakdown: points earned, max possible, why, and what would earn the rest.
|
|
40
|
+
// This is the single source of truth for the signal score.
|
|
41
|
+
export function computeScoreBreakdown(item, citationCount = 0) {
|
|
42
|
+
const validation = VALIDATION_SCORE[item.validation_status] || 0
|
|
43
|
+
const workflow = WORKFLOW_SCORE[item.workflow_status] || 0
|
|
44
|
+
const citations = Math.min(citationCount * 5, 15)
|
|
45
|
+
const acceptance = item.acceptance_criteria?.trim() ? 10 : 0
|
|
46
|
+
const tier = item.cu_tier ? 10 : 0
|
|
47
|
+
const evidence = item.evidence?.trim() ? 5 : 0
|
|
48
|
+
|
|
49
|
+
return [
|
|
50
|
+
{
|
|
51
|
+
signal: 'validation', points: validation, max: 40,
|
|
52
|
+
reason: item.validation_status || 'unvalidated',
|
|
53
|
+
hint: 'validate the assumption (label it, or link evidence)',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
signal: 'workflow', points: workflow, max: 20,
|
|
57
|
+
reason: item.workflow_status || 'todo',
|
|
58
|
+
hint: 'progress the item through the workflow',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
signal: 'discussion', points: citations, max: 15,
|
|
62
|
+
reason: `${citationCount} comment${citationCount === 1 ? '' : 's'}`,
|
|
63
|
+
hint: 'discuss it — undebated items hide risk',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
signal: 'acceptance criteria', points: acceptance, max: 10,
|
|
67
|
+
reason: acceptance ? 'defined' : 'none found',
|
|
68
|
+
hint: 'add an "Acceptance Criteria" section or checklist',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
signal: 'CU tier', points: tier, max: 10,
|
|
72
|
+
reason: item.cu_tier || 'not set',
|
|
73
|
+
hint: 'size it (basic / intermediate / advanced)',
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
signal: 'evidence', points: evidence, max: 5,
|
|
77
|
+
reason: evidence ? 'has description' : 'empty description',
|
|
78
|
+
hint: 'write down what is known',
|
|
79
|
+
},
|
|
80
|
+
]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function computeSignalScore(item, citationCount = 0) {
|
|
84
|
+
const total = computeScoreBreakdown(item, citationCount)
|
|
85
|
+
.reduce((sum, s) => sum + s.points, 0)
|
|
86
|
+
return Math.min(total, 100)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Largest missing signals first — "what would raise certainty on this item"
|
|
90
|
+
export function biggestGaps(item, citationCount = 0, limit = 3) {
|
|
91
|
+
return computeScoreBreakdown(item, citationCount)
|
|
92
|
+
.map(s => ({ ...s, gap: s.max - s.points }))
|
|
93
|
+
.filter(s => s.gap > 0)
|
|
94
|
+
.sort((a, b) => b.gap - a.gap)
|
|
95
|
+
.slice(0, limit)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function computeDimensionScore(item, config = DEFAULT_CU_CONFIG) {
|
|
99
|
+
const n = item.novelty_rating || 3
|
|
100
|
+
const c = item.complexity_rating || 3
|
|
101
|
+
const d = item.dependency_rating || 3
|
|
102
|
+
const w = config.dimensions
|
|
103
|
+
const weighted = n * w.novelty + c * w.complexity + d * w.dependencies
|
|
104
|
+
return Math.round(weighted * 20)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function computeCertaintyScore(item, citationCount = 0, config = DEFAULT_CU_CONFIG) {
|
|
108
|
+
const signalScore = computeSignalScore(item, citationCount)
|
|
109
|
+
const dimensionScore = computeDimensionScore(item, config)
|
|
110
|
+
const hasDimensions = item.novelty_rating || item.complexity_rating || item.dependency_rating
|
|
111
|
+
if (!hasDimensions) return signalScore
|
|
112
|
+
return Math.min(
|
|
113
|
+
Math.round(signalScore * config.signal_weight + dimensionScore * config.dimension_weight),
|
|
114
|
+
100
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function certaintyLevel(score) {
|
|
119
|
+
if (score >= 80) return 'high'
|
|
120
|
+
if (score >= 50) return 'medium'
|
|
121
|
+
if (score >= 20) return 'low'
|
|
122
|
+
return 'uncertain'
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const CU_TIERS = [
|
|
126
|
+
{ value: 'basic', label: 'Basic', multiplier: 1 },
|
|
127
|
+
{ value: 'intermediate', label: 'Intermediate', multiplier: 3 },
|
|
128
|
+
{ value: 'advanced', label: 'Advanced', multiplier: 6 },
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
export function computeCUMetrics(items) {
|
|
132
|
+
const total = items.length
|
|
133
|
+
const withCU = items.filter(i => i.cu_tier)
|
|
134
|
+
const completed = withCU.filter(i => i.workflow_status === 'done')
|
|
135
|
+
const accepted = completed.filter(i => i.validation_status === 'validated')
|
|
136
|
+
const totalCUValue = withCU.reduce((sum, i) => sum + (i.cu_value || 1), 0)
|
|
137
|
+
const completedCUValue = completed.reduce((sum, i) => sum + (i.cu_value || 1), 0)
|
|
138
|
+
const scores = items.map(i => i.certainty_score || 0)
|
|
139
|
+
const avgCertainty = total ? Math.round(scores.reduce((a, b) => a + b, 0) / total) : 0
|
|
140
|
+
return {
|
|
141
|
+
totalItems: total,
|
|
142
|
+
cuItems: withCU.length,
|
|
143
|
+
totalCUValue,
|
|
144
|
+
completedCUValue,
|
|
145
|
+
completionRate: withCU.length ? Math.round((completed.length / withCU.length) * 100) : 0,
|
|
146
|
+
integrityScore: completed.length ? Math.round((accepted.length / completed.length) * 100) : 0,
|
|
147
|
+
avgCertaintyScore: avgCertainty,
|
|
148
|
+
velocity: completedCUValue,
|
|
149
|
+
uphill: items.filter(i => (i.certainty_score || 0) < 50).length,
|
|
150
|
+
downhill: items.filter(i => (i.certainty_score || 0) >= 50).length,
|
|
151
|
+
}
|
|
152
|
+
}
|