mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-12 12:47:57 +02:00
fix(continuous-learning): /evolve never produces skill or agent candidates (#2664)
* fix(continuous-learning): cluster instincts by keyword overlap in /evolve
`cmd_evolve` grouped instincts by exact string equality of the whole
normalized trigger sentence. Triggers are free-form sentences, so every
instinct landed in its own bucket and `skill_candidates` was always empty.
`agent_candidates` is derived from `skill_candidates`, so agents never
generated either — `/evolve --generate` could only ever emit commands.
Measured on a 42-instinct project: 42 instincts produced 42 unique cluster
keys, largest cluster size 1.
Group on keyword overlap instead. Jaccard is the wrong metric here — trigger
keyword sets average ~7 words, so even clearly related pairs top out around
0.33 — so this uses the overlap coefficient (shared / smaller set) at 0.5,
plus a floor of 2 shared keywords so one incidental word cannot pull
unrelated instincts together. The same 42 instincts now yield 4 clusters.
Also unify the command/agent slug used by the preview and the writer. The
preview called `.replace('a ', '')`, which strips "a " anywhere in the
string, mangling "extracting data from Reddit" into
`/extracting-datfrom-R` while `--generate` wrote `extracting-data-from.md`.
Both paths now share `_evolved_command_name()` / `_evolved_agent_name()`.
Adds tests/scripts/instinct-cli-evolve.test.js, which fails on the previous
implementation (0 clusters instead of 1; preview name `extracting-datfrom-R`)
and covers the negative cases so unrelated triggers still stay apart.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(continuous-learning): correct clustering metric name in docstring
The docstring said "Jaccard" while the implementation uses the overlap
coefficient, which is the point of the change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(continuous-learning): generate every evolve candidate and cut slugs on word boundaries
_generate_evolved() wrote only skill_candidates[:5], workflow_instincts[:5]
and agent_candidates[:3]. On a project with 36 command candidates that meant
5 files and no warning, so the output read as complete while 86% of the
candidates were dropped.
Generation is now unbounded by default and takes a --limit N flag for callers
that want a cap. A cap that truncates says so:
Note: writing 3 of 36 command candidates (--limit 3); 33 skipped.
The analysis preview keeps showing five per kind but now names the remainder
("... and 31 more command candidates not shown") instead of presenting a
sample as the whole set.
Slugs were also cut with a hard slice, which split words mid-token and
produced /investigating-comple, /learning-about-compl and
/researching-mechanis. _truncate_slug() retreats to the last separator that
fits, and keeps the full head when the cut already lands on one, so
"analyzing large text files" stays /analyzing-large-text rather than losing
a word. A first word longer than the limit still falls back to a hard cut
because no boundary is available.
Shorter slugs collide more easily, and a collision used to mean one file
silently overwriting another. _assign_unique_slugs() suffixes duplicates
(-2, -3) and is called by both the preview and the writer over the same
ordered list, so advertised names and written names cannot drift apart.
Skill directory naming moved to _evolved_skill_name(); it previously used its
own inline slug expression, so it was the one truncation the shared helper
did not cover.
Adds tests/scripts/instinct-cli-evolve-generate.test.js: 7 cases covering
word-boundary cuts, the separator-aligned cut, unbounded generation, --limit
reporting, collision dedup, preview remainder and preview/writer agreement.
Six of the seven fail against the previous implementation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: haelyra <49814733+haelyra@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 5
haelyra
parent
b2bc8dcd14
commit
8a97868b5b
@@ -1145,6 +1145,163 @@ def cmd_export(args) -> int:
|
||||
# Evolve Command
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
# Words carrying no topical signal in a trigger sentence.
|
||||
TRIGGER_STOP_WORDS = {
|
||||
'when', 'while', 'the', 'and', 'or', 'to', 'of', 'in', 'on', 'for', 'with',
|
||||
'that', 'this', 'from', 'into', 'at', 'by', 'as', 'is', 'are', 'be', 'it',
|
||||
'its', 'they', 'them', 'their', 'you', 'your', 'new', 'any', 'all', 'about',
|
||||
'after', 'before', 'over', 'via', 'use', 'using', 'need', 'needs', 'not',
|
||||
}
|
||||
|
||||
# Overlap coefficient (shared / smaller set) two triggers need to cluster.
|
||||
# Jaccard is the wrong metric here: trigger keyword sets average ~7 words, so
|
||||
# even clearly-related pairs top out near 0.33 and nothing ever groups.
|
||||
TRIGGER_SIMILARITY_THRESHOLD = 0.5
|
||||
|
||||
# Guard against one incidental shared word pulling unrelated instincts together.
|
||||
TRIGGER_MIN_SHARED_KEYWORDS = 2
|
||||
|
||||
|
||||
# Evolved artefact slugs are trimmed to keep file names short. The cut has to
|
||||
# land on a word boundary: a hard slice produced names like
|
||||
# "investigating-comple" and "learning-about-compl", which read as typos.
|
||||
EVOLVED_SKILL_SLUG_LENGTH = 30
|
||||
EVOLVED_COMMAND_SLUG_LENGTH = 20
|
||||
EVOLVED_AGENT_SLUG_LENGTH = 20
|
||||
|
||||
|
||||
def _truncate_slug(slug: str, max_length: int) -> str:
|
||||
"""Trim a slug to max_length without splitting a word.
|
||||
|
||||
Falls back to a hard cut only when the first word is already longer than
|
||||
the limit, because then there is no boundary left to retreat to.
|
||||
"""
|
||||
if len(slug) <= max_length:
|
||||
return slug
|
||||
head = slug[:max_length]
|
||||
# The cut can already land on a separator, in which case head is a whole
|
||||
# sequence of words and dropping one more would lose a word for nothing.
|
||||
if slug[max_length] == '-':
|
||||
return head.rstrip('-')
|
||||
boundary = head.rfind('-')
|
||||
if boundary > 0:
|
||||
return head[:boundary]
|
||||
return head.strip('-')
|
||||
|
||||
|
||||
def _evolved_skill_name(trigger: str) -> str:
|
||||
"""Slug used for a generated skill directory. Shared by preview and writer."""
|
||||
return _truncate_slug(
|
||||
re.sub(r'[^a-z0-9]+', '-', str(trigger or '').lower()).strip('-'),
|
||||
EVOLVED_SKILL_SLUG_LENGTH,
|
||||
)
|
||||
|
||||
|
||||
def _evolved_command_name(trigger: str) -> str:
|
||||
"""Slug used for a generated command file. Shared by preview and writer."""
|
||||
stripped = str(trigger or 'unknown').lower().replace('when ', '').replace('implementing ', '')
|
||||
return _truncate_slug(
|
||||
re.sub(r'[^a-z0-9]+', '-', stripped).strip('-'),
|
||||
EVOLVED_COMMAND_SLUG_LENGTH,
|
||||
)
|
||||
|
||||
|
||||
def _evolved_agent_name(trigger: str) -> str:
|
||||
"""Slug used for a generated agent file. Shared by preview and writer."""
|
||||
return _truncate_slug(
|
||||
re.sub(r'[^a-z0-9]+', '-', str(trigger or '').lower()).strip('-'),
|
||||
EVOLVED_AGENT_SLUG_LENGTH,
|
||||
)
|
||||
|
||||
|
||||
# How many candidates of each kind the analysis prints before summarising the
|
||||
# rest. The preview is a sample, never the whole set, so it always says so.
|
||||
PREVIEW_LIMIT = 5
|
||||
|
||||
|
||||
def _print_preview_remainder(total: int, shown: int, noun: str) -> None:
|
||||
"""State how many candidates the preview left out.
|
||||
|
||||
Without this the truncated list reads as the complete set.
|
||||
"""
|
||||
if total > shown:
|
||||
print(f" ... and {total - shown} more {noun} not shown\n")
|
||||
|
||||
|
||||
def _assign_unique_slugs(items: list, slug_fn) -> list:
|
||||
"""Pair every item with a collision-free slug, preserving input order.
|
||||
|
||||
Word-boundary trimming makes collisions more likely because two triggers
|
||||
can now share a whole prefix, and a collision previously meant one
|
||||
generated file silently overwriting another. Preview and writer both call
|
||||
this over the same ordered list, so the names shown and the names written
|
||||
stay identical.
|
||||
"""
|
||||
used = set()
|
||||
assigned = []
|
||||
for item in items:
|
||||
base = slug_fn(item)
|
||||
if not base:
|
||||
assigned.append((item, ''))
|
||||
continue
|
||||
name = base
|
||||
suffix = 2
|
||||
while name in used:
|
||||
name = f"{base}-{suffix}"
|
||||
suffix += 1
|
||||
used.add(name)
|
||||
assigned.append((item, name))
|
||||
return assigned
|
||||
|
||||
|
||||
def _trigger_keywords(trigger: str) -> set:
|
||||
"""Reduce a trigger sentence to the words that carry its topic."""
|
||||
words = re.findall(r'[a-z0-9]+', str(trigger or '').lower())
|
||||
return {w for w in words if len(w) > 2 and w not in TRIGGER_STOP_WORDS}
|
||||
|
||||
|
||||
def _cluster_by_keyword_overlap(instincts: list) -> dict:
|
||||
"""Group instincts whose triggers share enough keywords.
|
||||
|
||||
Triggers are free-form sentences, so grouping on the whole normalized
|
||||
string puts every instinct in its own bucket and no skill or agent
|
||||
candidate is ever produced. Greedy clustering on keyword overlap groups
|
||||
the near-duplicate instincts that accumulate in a project.
|
||||
"""
|
||||
clusters = [] # [(shared_keywords, [instincts])]
|
||||
|
||||
for inst in instincts:
|
||||
keywords = _trigger_keywords(inst.get('trigger', ''))
|
||||
if not keywords:
|
||||
continue
|
||||
|
||||
best_index, best_score, best_shared = -1, 0.0, 0
|
||||
for index, (cluster_keywords, _members) in enumerate(clusters):
|
||||
shared = len(keywords & cluster_keywords)
|
||||
smaller = min(len(keywords), len(cluster_keywords))
|
||||
score = shared / smaller if smaller else 0.0
|
||||
if score > best_score:
|
||||
best_index, best_score, best_shared = index, score, shared
|
||||
|
||||
if (best_index >= 0
|
||||
and best_score >= TRIGGER_SIMILARITY_THRESHOLD
|
||||
and best_shared >= TRIGGER_MIN_SHARED_KEYWORDS):
|
||||
cluster_keywords, members = clusters[best_index]
|
||||
members.append(inst)
|
||||
# Keep the shared core so a cluster stays on one topic.
|
||||
clusters[best_index] = (cluster_keywords & keywords, members)
|
||||
else:
|
||||
clusters.append((keywords, [inst]))
|
||||
|
||||
grouped = {}
|
||||
for cluster_keywords, members in clusters:
|
||||
label = ' '.join(sorted(cluster_keywords)[:4]) or 'general'
|
||||
while label in grouped:
|
||||
label += ' +'
|
||||
grouped[label] = members
|
||||
return grouped
|
||||
|
||||
|
||||
def cmd_evolve(args) -> int:
|
||||
"""Analyze instincts and suggest evolutions to skills/commands/agents."""
|
||||
project = detect_project()
|
||||
@@ -1175,14 +1332,7 @@ def cmd_evolve(args) -> int:
|
||||
print(f"High confidence instincts (>=80%): {len(high_conf)}")
|
||||
|
||||
# Find clusters (instincts with similar triggers)
|
||||
trigger_clusters = defaultdict(list)
|
||||
for inst in instincts:
|
||||
trigger = inst.get('trigger', '')
|
||||
# Normalize trigger
|
||||
trigger_key = trigger.lower()
|
||||
for keyword in ['when', 'creating', 'writing', 'adding', 'implementing', 'testing']:
|
||||
trigger_key = trigger_key.replace(keyword, '').strip()
|
||||
trigger_clusters[trigger_key].append(inst)
|
||||
trigger_clusters = _cluster_by_keyword_overlap(instincts)
|
||||
|
||||
# Find clusters with 2+ instincts (good skill candidates)
|
||||
skill_candidates = []
|
||||
@@ -1203,8 +1353,8 @@ def cmd_evolve(args) -> int:
|
||||
print(f"\nPotential skill clusters found: {len(skill_candidates)}")
|
||||
|
||||
if skill_candidates:
|
||||
print(f"\n## SKILL CANDIDATES\n")
|
||||
for i, cand in enumerate(skill_candidates[:5], 1):
|
||||
print(f"\n## SKILL CANDIDATES ({len(skill_candidates)})\n")
|
||||
for i, cand in enumerate(skill_candidates[:PREVIEW_LIMIT], 1):
|
||||
scope_info = ', '.join(cand['scopes'])
|
||||
print(f"{i}. Cluster: \"{cand['trigger']}\"")
|
||||
print(f" Instincts: {len(cand['instincts'])}")
|
||||
@@ -1215,37 +1365,51 @@ def cmd_evolve(args) -> int:
|
||||
for inst in cand['instincts'][:3]:
|
||||
print(f" - {inst.get('id')} [{inst.get('scope', '?')}]")
|
||||
print()
|
||||
_print_preview_remainder(len(skill_candidates), PREVIEW_LIMIT, 'skill clusters')
|
||||
|
||||
# Command candidates (workflow instincts with high confidence)
|
||||
workflow_instincts = [i for i in instincts if i.get('domain') == 'workflow' and i.get('confidence', 0) >= 0.7]
|
||||
if workflow_instincts:
|
||||
print(f"\n## COMMAND CANDIDATES ({len(workflow_instincts)})\n")
|
||||
for inst in workflow_instincts[:5]:
|
||||
trigger = inst.get('trigger', 'unknown')
|
||||
cmd_name = trigger.replace('when ', '').replace('implementing ', '').replace('a ', '')
|
||||
cmd_name = cmd_name.replace(' ', '-')[:20]
|
||||
# Slugs come from the same helper the writer uses, over the same ordered
|
||||
# list, or the preview advertises names that differ from the files
|
||||
# --generate actually writes.
|
||||
for inst, cmd_name in _assign_unique_slugs(
|
||||
workflow_instincts,
|
||||
lambda i: _evolved_command_name(i.get('trigger', 'unknown')),
|
||||
)[:PREVIEW_LIMIT]:
|
||||
print(f" /{cmd_name}")
|
||||
print(f" From: {inst.get('id')} [{inst.get('scope', '?')}]")
|
||||
print(f" Confidence: {inst.get('confidence', 0.5):.0%}")
|
||||
print()
|
||||
_print_preview_remainder(len(workflow_instincts), PREVIEW_LIMIT, 'command candidates')
|
||||
|
||||
# Agent candidates (complex multi-step patterns)
|
||||
agent_candidates = [c for c in skill_candidates if len(c['instincts']) >= 3 and c['avg_confidence'] >= 0.75]
|
||||
if agent_candidates:
|
||||
print(f"\n## AGENT CANDIDATES ({len(agent_candidates)})\n")
|
||||
for cand in agent_candidates[:3]:
|
||||
agent_name = cand['trigger'].replace(' ', '-')[:20] + '-agent'
|
||||
for cand, agent_name in _assign_unique_slugs(
|
||||
agent_candidates,
|
||||
lambda c: _evolved_agent_name(str(c.get('trigger', '')).strip()),
|
||||
)[:PREVIEW_LIMIT]:
|
||||
print(f" {agent_name}")
|
||||
print(f" Covers {len(cand['instincts'])} instincts")
|
||||
print(f" Avg confidence: {cand['avg_confidence']:.0%}")
|
||||
print()
|
||||
_print_preview_remainder(len(agent_candidates), PREVIEW_LIMIT, 'agent candidates')
|
||||
|
||||
# Promotion candidates (project instincts that could be global)
|
||||
_show_promotion_candidates(project)
|
||||
|
||||
if args.generate:
|
||||
evolved_dir = project["evolved_dir"] if project["id"] != "global" else GLOBAL_EVOLVED_DIR
|
||||
generated = _generate_evolved(skill_candidates, workflow_instincts, agent_candidates, evolved_dir)
|
||||
generated = _generate_evolved(
|
||||
skill_candidates,
|
||||
workflow_instincts,
|
||||
agent_candidates,
|
||||
evolved_dir,
|
||||
limit=max(0, getattr(args, 'limit', 0) or 0),
|
||||
)
|
||||
if generated:
|
||||
print(f"\nGenerated {len(generated)} evolved structures:")
|
||||
for path in generated:
|
||||
@@ -1770,17 +1934,33 @@ def _cmd_projects_merge(args) -> int:
|
||||
# Generate Evolved Structures
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def _generate_evolved(skill_candidates: list, workflow_instincts: list, agent_candidates: list, evolved_dir: Path) -> list[str]:
|
||||
"""Generate skill/command/agent files from analyzed instinct clusters."""
|
||||
def _generate_evolved(skill_candidates: list, workflow_instincts: list, agent_candidates: list, evolved_dir: Path, limit: int = 0) -> list[str]:
|
||||
"""Generate skill/command/agent files from analyzed instinct clusters.
|
||||
|
||||
``limit`` caps how many candidates of each kind are written; 0 writes them
|
||||
all. Anything a cap leaves out is reported, because the previous fixed
|
||||
caps (5 skills, 5 commands, 3 agents) discarded most candidates without
|
||||
saying a word — 35 command candidates produced 5 files and no warning.
|
||||
"""
|
||||
generated = []
|
||||
|
||||
# Generate skills from top candidates
|
||||
for cand in skill_candidates[:5]:
|
||||
def bounded(assigned: list, kind: str) -> list:
|
||||
if limit and len(assigned) > limit:
|
||||
print(f"\nNote: writing {limit} of {len(assigned)} {kind} candidates "
|
||||
f"(--limit {limit}); {len(assigned) - limit} skipped.")
|
||||
return assigned[:limit]
|
||||
return assigned
|
||||
|
||||
# Generate skills from candidate clusters
|
||||
for cand, name in bounded(
|
||||
_assign_unique_slugs(
|
||||
skill_candidates,
|
||||
lambda c: _evolved_skill_name(str(c.get('trigger', '')).strip()),
|
||||
),
|
||||
'skill',
|
||||
):
|
||||
trigger = cand['trigger'].strip()
|
||||
if not trigger:
|
||||
continue
|
||||
name = re.sub(r'[^a-z0-9]+', '-', trigger.lower()).strip('-')[:30]
|
||||
if not name:
|
||||
if not trigger or not name:
|
||||
continue
|
||||
|
||||
skill_dir = evolved_dir / "skills" / name
|
||||
@@ -1802,10 +1982,13 @@ def _generate_evolved(skill_candidates: list, workflow_instincts: list, agent_ca
|
||||
generated.append(str(skill_dir / "SKILL.md"))
|
||||
|
||||
# Generate commands from workflow instincts
|
||||
for inst in workflow_instincts[:5]:
|
||||
trigger = inst.get('trigger', 'unknown')
|
||||
cmd_name = re.sub(r'[^a-z0-9]+', '-', trigger.lower().replace('when ', '').replace('implementing ', ''))
|
||||
cmd_name = cmd_name.strip('-')[:20]
|
||||
for inst, cmd_name in bounded(
|
||||
_assign_unique_slugs(
|
||||
workflow_instincts,
|
||||
lambda i: _evolved_command_name(i.get('trigger', 'unknown')),
|
||||
),
|
||||
'command',
|
||||
):
|
||||
if not cmd_name:
|
||||
continue
|
||||
|
||||
@@ -1819,9 +2002,13 @@ def _generate_evolved(skill_candidates: list, workflow_instincts: list, agent_ca
|
||||
generated.append(str(cmd_file))
|
||||
|
||||
# Generate agents from complex clusters
|
||||
for cand in agent_candidates[:3]:
|
||||
trigger = cand['trigger'].strip()
|
||||
agent_name = re.sub(r'[^a-z0-9]+', '-', trigger.lower()).strip('-')[:20]
|
||||
for cand, agent_name in bounded(
|
||||
_assign_unique_slugs(
|
||||
agent_candidates,
|
||||
lambda c: _evolved_agent_name(str(c.get('trigger', '')).strip()),
|
||||
),
|
||||
'agent',
|
||||
):
|
||||
if not agent_name:
|
||||
continue
|
||||
|
||||
@@ -2018,6 +2205,8 @@ def main() -> int:
|
||||
# Evolve
|
||||
evolve_parser = subparsers.add_parser('evolve', help='Analyze and evolve instincts')
|
||||
evolve_parser.add_argument('--generate', action='store_true', help='Generate evolved structures')
|
||||
evolve_parser.add_argument('--limit', type=int, default=0, metavar='N',
|
||||
help='Max candidates of each kind to generate (default: 0 = all)')
|
||||
|
||||
# Promote (new in v2.1)
|
||||
promote_parser = subparsers.add_parser('promote', help='Promote project instincts to global scope')
|
||||
|
||||
Reference in New Issue
Block a user