Add Venture Discovery V0.3 novelty gates

This commit is contained in:
Daniel Maddern 2026-08-16 16:39:46 +07:00
parent 526e280b1b
commit ea54d53bf8
8 changed files with 1524 additions and 47 deletions

View file

@ -11,11 +11,12 @@ from decimal import Decimal
from typing import Any
from collections import Counter, defaultdict
from django.core.exceptions import ObjectDoesNotExist
from django.db import close_old_connections, connection
from django.utils import timezone
from control_plane.events.bus import EventBus
from control_plane.ventures.models import CapabilityPriority, CapabilityStatus, CompanyBoardReview, CompanyCapabilityRequirement, CompanyMandate, CompanyProposal, CompanyProposalStatus, EvidenceTier, ICDecision, ICDecisionType, ICDiligence, ICQuestion, ICResponse, OverlapClassification, PortfolioCapabilityGap, PortfolioICReview, VentureArtifact, VentureCapabilityDemand, VentureCohort, VentureCohortMember, VentureCollision, VentureThesis, VentureThesisFingerprint
from control_plane.ventures.models import CapabilityPriority, CapabilityStatus, CohortIdeationMandate, CompanyBoardReview, CompanyCapabilityRequirement, CompanyMandate, CompanyProposal, CompanyProposalStatus, EvidenceTier, ICDecision, ICDecisionType, ICDiligence, ICQuestion, ICResponse, NoveltyGateDecision, OpportunityTerritory, OverlapClassification, PortfolioCapabilityGap, PortfolioICReview, PortfolioSaturationAnalysis, PortfolioThesis, PortfolioThesisCluster, PortfolioThesisStatus, ThesisMatchClassification, VentureArtifact, VentureCapabilityDemand, VentureCohort, VentureCohortMember, VentureCollision, VentureGenerationRejection, VentureThesis, VentureThesisFingerprint
from graph.models import GraphRun, GraphRunStatus
from model_router.providers import extract_json_object
from model_router.policy import model_for_role
@ -28,6 +29,10 @@ SCORE_DIMENSIONS = ["Demand Evidence", "Time-to-First-Dollar Attractiveness", "C
SCORE_DEFINITIONS = {dimension: "100 = highly attractive; 0 = highly unattractive" for dimension in SCORE_DIMENSIONS}
EVIDENCE_CEILINGS = {EvidenceTier.TIER_0_THESIS: 35, EvidenceTier.TIER_1_PUBLIC_EVIDENCE: 55, EvidenceTier.TIER_2_CUSTOMER_SIGNAL: 70, EvidenceTier.TIER_3_WILLINGNESS_TO_PAY: 85, EvidenceTier.TIER_4_PAID_CUSTOMER: 95, EvidenceTier.TIER_5_REPEATABLE_TRACTION: 100}
AI_NATIVE_POLICY = {"preference": "Prefer AI-native opportunities where Artifex can build or deliver most value with existing agents, local inference, and owned workflow capabilities.", "preferred_classes": ["AI wrapper platforms", "AI-enabled services", "AI infrastructure / developer tools", "AI research / intelligence products", "AI automation for SMB / enterprise workflows"], "soft_portfolio_targets": {"ai_wrapper_platforms": "40-50%", "ai_enabled_services": "20-30%", "developer_infrastructure_intelligence": "10-20%", "non_ai_economic_outliers": "10-20%"}, "penalize_concentration": ["Shopify/ecommerce", "generic audits", "emergency fix services", "one-off consulting", "identical outbound-led service models"], "do_not": "Do not let AI novelty outweigh customer demand."}
DEFAULT_HARD_EXCLUSIONS = ["AI RFP response drafting / proposal automation", "SaaS churn prediction / retention playbook generation"]
DEFAULT_SOFT_EXCLUSIONS = ["generic contract scanners", "Shopify audit products", "generic AI content generators", "generic productized consulting", "generic one-off audits", "generic chatbot wrappers"]
DEFAULT_TERRITORY_ALLOCATION = [OpportunityTerritory.VERTICAL_AI_WRAPPERS, OpportunityTerritory.VERTICAL_AI_WRAPPERS, OpportunityTerritory.ENTERPRISE_WORKFLOW_AUTOMATION, OpportunityTerritory.SMB_AUTOMATION, OpportunityTerritory.DEVELOPER_AI_INFRASTRUCTURE, OpportunityTerritory.INTELLIGENCE_MONITORING, OpportunityTerritory.DATA_DOCUMENT_AUTOMATION, OpportunityTerritory.AI_ENABLED_SERVICE_TO_PLATFORM, OpportunityTerritory.OPEN_CATEGORY, OpportunityTerritory.OPEN_CATEGORY]
DEFAULT_DUPLICATE_POLICY = {"max_near_duplicate_per_thesis_per_cohort": 1, "max_competitive_per_thesis_per_cohort": 2, "max_attempts_per_slot": 3}
class VentureDiscoveryService:
@ -51,8 +56,12 @@ class VentureDiscoveryService:
self._artifact(None, mandate, "VENTURE_MANDATE", "Venture Discovery V0 Mandate", {"objective": mandate.objective, "constraints": mandate.constraints, "optimization_targets": mandate.optimization_targets}, self._readable_mandate(mandate), "venture_discovery")
return mandate
def generate_single_company(self, mandate: CompanyMandate, *, graph_run=None, ideation_index: int | None = None) -> CompanyProposal:
payload, source = self._company_payload(mandate, ideation_index=ideation_index)
def generate_single_company(self, mandate: CompanyMandate, *, graph_run=None, ideation_index: int | None = None, ideation_context: dict[str, Any] | None = None, payload: dict[str, Any] | None = None, source: str | None = None) -> CompanyProposal:
if payload is None or source is None:
payload, source = self._company_payload(mandate, ideation_index=ideation_index, ideation_context=ideation_context)
return self._create_company_from_payload(mandate, payload, source, graph_run=graph_run)
def _create_company_from_payload(self, mandate: CompanyMandate, payload: dict[str, Any], source: str, *, graph_run=None) -> CompanyProposal:
pitch = self._pitch(payload, fallback=source == "deterministic_fallback")
confidence = self._confidence(payload.get("confidence", 0.55))
evidence = self._as_list(payload.get("market_evidence", []))
@ -240,30 +249,91 @@ class VentureDiscoveryService:
return decision
def prepare_cohort(self, *, size: int = 10, graph_run=None, concurrency: int = 1) -> VentureCohort:
self.seed_dogfood_thesis_registry()
mandate = self.create_v0_mandate()
cohort_id = f"VDV02-{timezone.now().strftime('%Y%m%d%H%M%S')}-{hashlib.sha1(str(mandate.id).encode()).hexdigest()[:8]}"
return VentureCohort.objects.create(cohort_id=cohort_id, mandate=mandate, cohort_size=size, graph_run=graph_run, concurrency=concurrency, status="PREPARING", graph_versions={"cohort": "venture_discovery_cohort v1", "company": "venture_discovery v1"}, research_policy={"stage_a": "lightweight_all", "deeper_research": "top_5_if_required", "no_spend": True, "no_customer_outreach": True}, scoring_policy={"dimensions": SCORE_DEFINITIONS}, evidence_calibration_policy={tier: ceiling for tier, ceiling in EVIDENCE_CEILINGS.items()}, metadata={"real_spend": 0, "real_customer_outreach": False})
cohort_id = f"VDV03-{timezone.now().strftime('%Y%m%d%H%M%S')}-{hashlib.sha1(str(mandate.id).encode()).hexdigest()[:8]}"
return VentureCohort.objects.create(cohort_id=cohort_id, mandate=mandate, cohort_size=size, graph_run=graph_run, concurrency=concurrency, status="PREPARING", graph_versions={"cohort": "venture_discovery_cohort v2", "company": "venture_discovery v1"}, research_policy={"stage_a": "lightweight_all", "deeper_research": "top_5_if_required", "no_spend": True, "no_customer_outreach": True}, scoring_policy={"dimensions": SCORE_DEFINITIONS, "duplicate_policy": DEFAULT_DUPLICATE_POLICY}, evidence_calibration_policy={tier: ceiling for tier, ceiling in EVIDENCE_CEILINGS.items()}, metadata={"real_spend": 0, "real_customer_outreach": False, "milestone": "VENTURE_DISCOVERY_V0.3"})
def portfolio_thesis_review(self, cohort: VentureCohort) -> dict[str, Any]:
self.seed_dogfood_thesis_registry()
registry = self.registry_snapshot()
review = {"saturated_thesis_areas": [row for row in registry if row["status"] == PortfolioThesisStatus.SATURATED], "active_candidates": [row for row in registry if row["status"] == PortfolioThesisStatus.ACTIVE_CANDIDATE], "registry_size": len(registry), "policy": DEFAULT_DUPLICATE_POLICY}
cohort.metadata = {**cohort.metadata, "portfolio_thesis_review": review}
cohort.save(update_fields=["metadata", "updated_at"])
return review
def create_ideation_mandate(self, cohort: VentureCohort) -> CohortIdeationMandate:
territories = self.allocate_territories(cohort.cohort_size)
review = cohort.metadata.get("portfolio_thesis_review", {}) if isinstance(cohort.metadata, dict) else {}
objective = "Intentionally search new venture territory while preserving independent ideation and avoiding known saturated thesis areas."
mandate, _ = CohortIdeationMandate.objects.update_or_create(
cohort=cohort,
defaults={
"objective": objective,
"desired_opportunity_classes": AI_NATIVE_POLICY["preferred_classes"],
"hard_exclusions": DEFAULT_HARD_EXCLUSIONS,
"soft_exclusions": DEFAULT_SOFT_EXCLUSIONS,
"opportunity_territories": territories,
"portfolio_gaps": ["non-RFP enterprise workflows", "non-churn SaaS intelligence", "developer infrastructure", "document/data automation"],
"diversity_preferences": {"avoid_repeating_saturated_theses": True, "max_near_duplicate_per_thesis_per_cohort": 1, "max_competitive_per_thesis_per_cohort": 2, "ai_native_preferred_not_required": True},
"registry_snapshot": {"version": timezone.now().isoformat(), "theses": self.registry_snapshot()},
"metadata": {"portfolio_thesis_review": review, "cohort_ideation_brief": self.ideation_brief_text(review, territories)},
},
)
self._artifact(None, cohort.mandate, "COHORT_IDEATION_MANDATE", "Cohort Ideation Mandate", self.ideation_mandate_payload(mandate), mandate.metadata["cohort_ideation_brief"], "Portfolio IC", graph_run=cohort.graph_run)
return mandate
def allocate_search_territories(self, cohort: VentureCohort) -> list[str]:
mandate = getattr(cohort, "ideation_mandate", None) or self.create_ideation_mandate(cohort)
territories = self.allocate_territories(cohort.cohort_size)
mandate.opportunity_territories = territories
mandate.save(update_fields=["opportunity_territories", "updated_at"])
return territories
def generate_independent_proposals(self, cohort: VentureCohort) -> list[CompanyProposal]:
started = time.monotonic()
def generate(index: int) -> tuple[int, str]:
proposal = self.generate_single_company(cohort.mandate, ideation_index=index + 1)
return index, str(proposal.id)
generated = self._bounded_map(range(cohort.cohort_size), generate, cohort.concurrency)
peak_concurrency = self._last_bounded_map_peak
accepted_payloads: list[dict[str, Any]] = []
proposals = []
for index, proposal_id in sorted(generated, key=lambda item: item[0]):
proposal = CompanyProposal.objects.get(id=proposal_id)
mandate = getattr(cohort, "ideation_mandate", None) or self.create_ideation_mandate(cohort)
max_attempts = int(cohort.scoring_policy.get("duplicate_policy", DEFAULT_DUPLICATE_POLICY).get("max_attempts_per_slot", 3))
attempts = hard_rejections = duplicate_rejections = soft_reviews = failed_slots = model_requests = 0
for index in range(cohort.cohort_size):
territory = mandate.opportunity_territories[index % len(mandate.opportunity_territories)] if mandate.opportunity_territories else OpportunityTerritory.OPEN_CATEGORY
accepted: tuple[dict[str, Any], str, dict[str, Any]] | None = None
for attempt in range(1, max_attempts + 1):
attempts += 1
model_requests += 1
context = {"hard_exclusions": mandate.hard_exclusions, "soft_exclusions": mandate.soft_exclusions, "territory": territory, "brief": self.ideation_mandate_payload(mandate)}
payload, source = self._company_payload(cohort.mandate, ideation_index=index + 1, ideation_context=context)
gate = self.novelty_gate(payload, cohort, accepted_payloads, mandate, territory=str(territory))
if gate["decision"] == NoveltyGateDecision.ACCEPT:
accepted = (payload, source, gate)
break
if gate["decision"] == NoveltyGateDecision.REGENERATE_HARD_EXCLUSION:
hard_rejections += 1
elif gate["decision"] == NoveltyGateDecision.REGENERATE_DUPLICATE:
duplicate_rejections += 1
elif gate["decision"] == NoveltyGateDecision.REVIEW_SOFT_EXCLUSION:
soft_reviews += 1
VentureGenerationRejection.objects.create(cohort=cohort, slot_index=index + 1, attempt=attempt, decision=gate["decision"], reason=gate["explanation"], candidate=payload, matched_thesis=gate.get("matched_thesis"), similarity_score=float(gate.get("similarity_score", 0.0)), metadata={"territory": str(territory), "classification": gate.get("classification")})
if accepted is None:
failed_slots += 1
VentureGenerationRejection.objects.create(cohort=cohort, slot_index=index + 1, attempt=max_attempts, decision=NoveltyGateDecision.FAILED_IDEATION, reason="max ideation attempts exhausted", metadata={"territory": str(territory)})
continue
payload, source, gate = accepted
proposal = self.generate_single_company(cohort.mandate, ideation_index=index + 1, payload=payload, source=source)
generation_index = index + 1
proposal.metadata = {**proposal.metadata, "cohort_id": cohort.cohort_id, "independent_generation_index": generation_index, "prior_ideas_visible": False}
proposal.metadata = {**proposal.metadata, "cohort_id": cohort.cohort_id, "independent_generation_index": generation_index, "prior_ideas_visible": False, "search_territory": str(territory), "novelty_gate": {k: v for k, v in gate.items() if k != "matched_thesis"}}
proposal.save(update_fields=["metadata", "updated_at"])
VentureCohortMember.objects.create(cohort=cohort, proposal=proposal, metadata={"generation_index": generation_index})
proposals.append(proposal)
accepted_payloads.append(payload)
cohort.status = "PROPOSALS_GENERATED"
cohort.metrics = {**cohort.metrics, "proposal_generation_runtime_seconds": round(time.monotonic() - started, 2), "proposal_generation_peak_concurrency": peak_concurrency}
cohort.metrics = {**cohort.metrics, "requested_company_count": cohort.cohort_size, "generation_attempts": attempts, "accepted_proposals": len(proposals), "hard_exclusion_rejections": hard_rejections, "duplicate_rejections": duplicate_rejections, "soft_exclusion_reviews": soft_reviews, "failed_slots": failed_slots, "average_attempts_per_company": round(attempts / max(1, len(proposals)), 2), "generation_model_requests": model_requests, "proposal_generation_runtime_seconds": round(time.monotonic() - started, 2), "proposal_generation_peak_concurrency": 1, "peak_concurrency": max(cohort.metrics.get("peak_concurrency", 0), 1)}
cohort.save(update_fields=["metrics", "status", "updated_at"])
if len(proposals) != cohort.cohort_size:
cohort.status = "FAILED_IDEATION"
cohort.save(update_fields=["status", "updated_at"])
return proposals
def research_cohort(self, cohort: VentureCohort) -> None:
@ -283,6 +353,32 @@ class VentureDiscoveryService:
def fingerprint_cohort(self, cohort: VentureCohort) -> list[VentureThesisFingerprint]:
return [self.fingerprint_proposal(member.proposal) for member in cohort.members.select_related("proposal")]
def cluster_theses(self, cohort: VentureCohort) -> list[PortfolioThesisCluster]:
clusters = []
for member in cohort.members.select_related("proposal"):
proposal = member.proposal
self.fingerprint_proposal(proposal)
match = self.match_portfolio_thesis(proposal)
thesis = match.get("matched_thesis")
if thesis is None:
thesis = self.create_portfolio_thesis_from_proposal(proposal, cohort)
match = {**match, "matched_thesis": thesis, "classification": ThesisMatchClassification.NEW_THESIS, "similarity_score": 1.0, "explanation": "Created a new portfolio thesis for this opportunity area."}
cluster, _ = PortfolioThesisCluster.objects.update_or_create(
cohort=cohort,
proposal=proposal,
defaults={
"portfolio_thesis": thesis,
"similarity_score": float(match["similarity_score"]),
"classification": match["classification"],
"explanation": match["explanation"],
"metadata": {"canonical_name": thesis.canonical_name},
},
)
clusters.append(cluster)
proposal.metadata = {**proposal.metadata, "portfolio_thesis_id": str(thesis.id), "portfolio_thesis": thesis.canonical_name, "thesis_match": {"classification": cluster.classification, "similarity_score": cluster.similarity_score, "explanation": cluster.explanation}}
proposal.save(update_fields=["metadata", "updated_at"])
return clusters
def analyze_collisions(self, cohort: VentureCohort) -> list[VentureCollision]:
collisions = []
proposals = [member.proposal for member in cohort.members.select_related("proposal")]
@ -344,6 +440,77 @@ class VentureDiscoveryService:
cohort.save(update_fields=["status", "updated_at"])
return review
def saturation_analysis(self, cohort: VentureCohort) -> list[dict[str, Any]]:
rows = []
by_thesis: dict[str, list[PortfolioThesisCluster]] = defaultdict(list)
for cluster in cohort.thesis_clusters.select_related("portfolio_thesis", "proposal"):
if cluster.portfolio_thesis_id:
by_thesis[str(cluster.portfolio_thesis_id)].append(cluster)
for clusters in by_thesis.values():
thesis = clusters[0].portfolio_thesis
scores = []
classifications = Counter(cluster.classification for cluster in clusters)
best_proposal = None
for cluster in clusters:
diligence = cluster.proposal.ic_diligence.order_by("-created_at").first()
if diligence and hasattr(diligence, "decision"):
scores.append(float(diligence.decision.composite_score))
if best_proposal is None or diligence.decision.composite_score > best_proposal[1]:
best_proposal = (cluster.proposal, float(diligence.decision.composite_score))
best_score = max(scores) if scores else 0.0
spread = round(max(scores) - min(scores), 1) if len(scores) > 1 else 0.0
proposal_count = len(clusters)
near_count = classifications.get(ThesisMatchClassification.NEAR_DUPLICATE, 0) + classifications.get(ThesisMatchClassification.DUPLICATE, 0)
if proposal_count >= 3 and near_count >= 2 and spread <= 15:
recommendation = PortfolioThesisStatus.SATURATED
rationale = "Multiple semantically similar proposals with limited score spread; more ideation is unlikely to add information without a new wedge."
elif proposal_count >= 2 and best_score >= 60 and spread <= 10:
recommendation = PortfolioThesisStatus.SATURATED
rationale = "A differentiated winner appears to have emerged; additional near-term ideation should move elsewhere."
elif best_score >= 55:
recommendation = PortfolioThesisStatus.ACTIVE_CANDIDATE
rationale = "Strong enough to remain active but not yet saturated."
else:
recommendation = PortfolioThesisStatus.EXPLORED
rationale = "Explored without enough evidence or score strength to prioritize."
analysis, _ = PortfolioSaturationAnalysis.objects.update_or_create(
cohort=cohort,
portfolio_thesis=thesis,
defaults={"proposal_count": proposal_count, "best_ic_score": best_score, "score_spread": spread, "status_recommendation": recommendation, "rationale": rationale, "metadata": {"classification_counts": dict(classifications), "best_company": best_proposal[0].title if best_proposal else ""}},
)
rows.append({"thesis": thesis.canonical_name, "proposal_count": analysis.proposal_count, "best_ic_score": analysis.best_ic_score, "score_spread": analysis.score_spread, "status_recommendation": analysis.status_recommendation, "rationale": analysis.rationale})
if hasattr(cohort, "portfolio_review"):
review = cohort.portfolio_review
review.metadata = {**review.metadata, "saturation_analysis": rows}
review.save(update_fields=["metadata", "updated_at"])
cohort.metadata = {**cohort.metadata, "saturation_analysis": rows}
cohort.save(update_fields=["metadata", "updated_at"])
return rows
def update_thesis_registry(self, cohort: VentureCohort) -> list[dict[str, Any]]:
updates = []
for analysis in cohort.saturation_analyses.select_related("portfolio_thesis"):
thesis = analysis.portfolio_thesis
clusters = list(cohort.thesis_clusters.filter(portfolio_thesis=thesis).select_related("proposal"))
best = None
for cluster in clusters:
diligence = cluster.proposal.ic_diligence.order_by("-created_at").first()
if diligence and hasattr(diligence, "decision") and (best is None or diligence.decision.composite_score > best[1]):
best = (cluster.proposal, float(diligence.decision.composite_score))
thesis.proposal_count = thesis.proposal_clusters.count()
thesis.best_ic_score = max(float(thesis.best_ic_score or 0.0), analysis.best_ic_score)
if best and (thesis.best_company is None or best[1] >= thesis.best_ic_score):
thesis.best_company = best[0]
thesis.first_seen_cohort = thesis.first_seen_cohort or cohort
thesis.last_seen_cohort = cohort
thesis.status = analysis.status_recommendation
thesis.metadata = {**thesis.metadata, "last_saturation_rationale": analysis.rationale, "last_reopen_reason": thesis.metadata.get("last_reopen_reason", "")}
thesis.save(update_fields=["proposal_count", "best_ic_score", "best_company", "first_seen_cohort", "last_seen_cohort", "status", "metadata", "updated_at"])
updates.append({"thesis": thesis.canonical_name, "status": thesis.status, "proposal_count": thesis.proposal_count, "best_ic_score": thesis.best_ic_score})
cohort.metadata = {**cohort.metadata, "thesis_registry_after": self.registry_snapshot()}
cohort.save(update_fields=["metadata", "updated_at"])
return updates
def aggregate_capability_demand(self, cohort: VentureCohort, *, top_3_only: bool = False) -> list[dict[str, Any]]:
members = cohort.members.filter(is_top_3=True) if top_3_only else cohort.members.all()
proposals = [member.proposal for member in members.select_related("proposal")]
@ -375,7 +542,11 @@ class VentureDiscoveryService:
def produce_cohort_report(self, cohort: VentureCohort) -> VentureArtifact:
review = cohort.portfolio_review
content = {"cohort_id": cohort.cohort_id, "mandate": cohort.mandate.objective, "runtime": cohort.metrics, "total_spend": 0, "customer_outreach": "none", "rankings": review.rankings, "top_3": review.top_3, "collisions": self._collision_summary(cohort), "portfolio_concentration": review.concentration, "capability_demand": review.capability_demand, "top_3_capability_gaps": review.top_3_capability_gaps, "recommended_build_priorities": review.recommended_build_priorities}
ideation_mandate = getattr(cohort, "ideation_mandate", None)
clusters = [{"company": cluster.proposal.title, "portfolio_thesis": cluster.portfolio_thesis.canonical_name if cluster.portfolio_thesis else "", "classification": cluster.classification, "similarity_score": cluster.similarity_score, "explanation": cluster.explanation} for cluster in cohort.thesis_clusters.select_related("proposal", "portfolio_thesis").order_by("created_at")]
generation_rejections = [{"slot_index": rejection.slot_index, "attempt": rejection.attempt, "decision": rejection.decision, "reason": rejection.reason, "candidate_title": rejection.candidate.get("title", "") if isinstance(rejection.candidate, dict) else "", "similarity_score": rejection.similarity_score} for rejection in cohort.generation_rejections.order_by("slot_index", "attempt")]
saturation = [{"thesis": analysis.portfolio_thesis.canonical_name, "proposal_count": analysis.proposal_count, "best_ic_score": analysis.best_ic_score, "score_spread": analysis.score_spread, "status_recommendation": analysis.status_recommendation, "rationale": analysis.rationale} for analysis in cohort.saturation_analyses.select_related("portfolio_thesis").order_by("-proposal_count")]
content = {"cohort_id": cohort.cohort_id, "mandate": cohort.mandate.objective, "ideation_mandate": self.ideation_mandate_payload(ideation_mandate) if ideation_mandate else {}, "hard_exclusions": ideation_mandate.hard_exclusions if ideation_mandate else [], "soft_exclusions": ideation_mandate.soft_exclusions if ideation_mandate else [], "search_territories": ideation_mandate.opportunity_territories if ideation_mandate else [], "generation_rejections": generation_rejections, "thesis_registry_before": (ideation_mandate.registry_snapshot.get("theses", []) if ideation_mandate else []), "thesis_registry_after": cohort.metadata.get("thesis_registry_after", []), "thesis_clusters": clusters, "saturation_analysis": saturation, "idea_diversity_metrics": self.diversity_metrics(cohort), "runtime": cohort.metrics, "total_spend": 0, "customer_outreach": "none", "rankings": review.rankings, "top_3": review.top_3, "collisions": self._collision_summary(cohort), "portfolio_concentration": review.concentration, "capability_demand": review.capability_demand, "top_3_capability_gaps": review.top_3_capability_gaps, "recommended_build_priorities": review.recommended_build_priorities}
readable = self._readable_cohort_report(content)
cohort.status = "COMPLETE"
cohort.save(update_fields=["status", "updated_at"])
@ -443,7 +614,10 @@ class VentureDiscoveryService:
return result
def fingerprint_proposal(self, proposal: CompanyProposal) -> VentureThesisFingerprint:
existing = getattr(proposal, "fingerprint", None)
try:
existing = proposal.fingerprint
except ObjectDoesNotExist:
existing = None
data = self._fingerprint_data(proposal)
digest = hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()
fields = {**data, "fingerprint_hash": digest, "metadata": {"deterministic": True}}
@ -473,11 +647,203 @@ class VentureDiscoveryService:
classification = OverlapClassification.NONE
return {"classification": classification, "similarity_score": score, "overlapping_dimensions": overlap, "explanation": f"Overlap {overlap}; text similarity {text_score:.2f}."}
def _company_payload(self, mandate: CompanyMandate, *, ideation_index: int | None = None) -> tuple[dict[str, Any], str]:
def seed_dogfood_thesis_registry(self) -> None:
seeds = [
("AI RFP response automation for B2B SaaS", "AI-assisted RFP/proposal drafting and response automation for B2B SaaS sales teams.", "AI wrapper platforms", PortfolioThesisStatus.SATURATED),
("AI churn/retention intelligence for SaaS", "AI analysis of SaaS customer health data to predict churn and generate retention playbooks.", "AI-enabled services", PortfolioThesisStatus.SATURATED),
("Shopify compliance/audit", "Shopify or ecommerce audit/compliance products and services.", "SMB automation", PortfolioThesisStatus.EXPLORED),
("Freelancer contract risk scanning", "Contract risk review and clause scanning for freelancers and solo operators.", "Data/document automation", PortfolioThesisStatus.ACTIVE_CANDIDATE),
]
for name, thesis, category, status in seeds:
existing = PortfolioThesis.objects.filter(canonical_name=name).first()
historical = self.historical_thesis_stats(name)
defaults = {"concise_thesis": thesis, "category": category, "status": status, "metadata": {"dogfood_seed": True, "historical": historical}}
if historical["proposal_count"]:
defaults.update({"proposal_count": historical["proposal_count"], "best_ic_score": historical["best_ic_score"], "best_company": historical["best_company"]})
if existing is None:
PortfolioThesis.objects.create(canonical_name=name, fingerprint=hashlib.sha256(name.lower().encode()).hexdigest(), **defaults)
else:
if existing.metadata.get("last_reopen_reason"):
defaults.pop("status", None)
for key, value in defaults.items():
setattr(existing, key, value)
existing.save(update_fields=[*defaults.keys(), "updated_at"])
def historical_thesis_stats(self, canonical_name: str) -> dict[str, Any]:
proposals = [proposal for proposal in CompanyProposal.objects.all() if self.canonical_thesis_name(self.payload_from_proposal(proposal)) == canonical_name]
best_score = 0.0
best_company = None
for proposal in proposals:
diligence = proposal.ic_diligence.order_by("-created_at").first()
if diligence and hasattr(diligence, "decision") and diligence.decision.composite_score > best_score:
best_score = float(diligence.decision.composite_score)
best_company = proposal
return {"proposal_count": len(proposals), "best_ic_score": best_score, "best_company": best_company}
def registry_snapshot(self) -> list[dict[str, Any]]:
return [{"id": str(thesis.id), "canonical_name": thesis.canonical_name, "status": thesis.status, "proposal_count": thesis.proposal_count, "best_ic_score": thesis.best_ic_score, "best_company": thesis.best_company.title if thesis.best_company else "", "last_seen_cohort": thesis.last_seen_cohort.cohort_id if thesis.last_seen_cohort else "", "category": thesis.category} for thesis in PortfolioThesis.objects.order_by("canonical_name")]
def reopen_portfolio_thesis(self, thesis: PortfolioThesis, *, reason: str, status: str = PortfolioThesisStatus.ACTIVE_CANDIDATE) -> PortfolioThesis:
thesis.status = status
thesis.metadata = {**thesis.metadata, "last_reopen_reason": reason, "reopened_at": timezone.now().isoformat()}
thesis.save(update_fields=["status", "metadata", "updated_at"])
return thesis
def allocate_territories(self, size: int) -> list[str]:
base = [item.value for item in DEFAULT_TERRITORY_ALLOCATION]
return [base[index % len(base)] for index in range(max(1, size))]
def ideation_mandate_payload(self, mandate: CohortIdeationMandate) -> dict[str, Any]:
return {"objective": mandate.objective, "desired_opportunity_classes": mandate.desired_opportunity_classes, "hard_exclusions": mandate.hard_exclusions, "soft_exclusions": mandate.soft_exclusions, "opportunity_territories": mandate.opportunity_territories, "portfolio_gaps": mandate.portfolio_gaps, "diversity_preferences": mandate.diversity_preferences, "registry_snapshot": mandate.registry_snapshot}
def ideation_brief_text(self, review: dict[str, Any], territories: list[str]) -> str:
return "\n".join([
"COHORT IDEATION BRIEF",
"Mandate: intentionally search new venture territory while preserving independent ideation.",
"Desired opportunity classes: AI wrapper platforms; agentic workflow products; vertical copilots; AI-enabled services; developer / AI infrastructure; intelligence products; automation products; service -> platform paths; recurring revenue; owned-compute leverage.",
"HARD EXCLUSIONS: " + "; ".join(DEFAULT_HARD_EXCLUSIONS),
"SOFT EXCLUSIONS: " + "; ".join(DEFAULT_SOFT_EXCLUSIONS),
"SATURATED THESIS AREAS: " + "; ".join(row["canonical_name"] for row in review.get("saturated_thesis_areas", [])),
"ACTIVE CANDIDATES: " + "; ".join(row["canonical_name"] for row in review.get("active_candidates", [])),
"UNDEREXPLORED TERRITORIES: developer infrastructure; intelligence monitoring; data/document automation; non-RFP enterprise workflows; non-churn SaaS intelligence.",
"SEARCH TERRITORY ALLOCATION: " + "; ".join(territories),
])
def novelty_gate(self, payload: dict[str, Any], cohort: VentureCohort, accepted_payloads: list[dict[str, Any]], mandate: CohortIdeationMandate, *, territory: str) -> dict[str, Any]:
hard = self.hard_exclusion_match(payload, mandate.hard_exclusions)
if hard:
return {"decision": NoveltyGateDecision.REGENERATE_HARD_EXCLUSION, "classification": ThesisMatchClassification.DUPLICATE, "similarity_score": 1.0, "explanation": f"Hard-excluded thesis area: {hard}", "territory": territory}
soft = self.soft_exclusion_match(payload, mandate.soft_exclusions)
if soft and not self.has_soft_exception(payload):
return {"decision": NoveltyGateDecision.REVIEW_SOFT_EXCLUSION, "classification": ThesisMatchClassification.ADJACENT, "similarity_score": 0.55, "explanation": f"Soft-excluded area lacks material differentiation: {soft}", "territory": territory}
registry_match = self.match_payload_to_registry(payload)
thesis = registry_match.get("matched_thesis")
if thesis and thesis.status == PortfolioThesisStatus.SATURATED and registry_match["classification"] in {ThesisMatchClassification.NEAR_DUPLICATE, ThesisMatchClassification.DUPLICATE, ThesisMatchClassification.COMPETITIVE}:
return {"decision": NoveltyGateDecision.REGENERATE_DUPLICATE, **registry_match, "explanation": "Matched saturated thesis: " + registry_match["explanation"], "territory": territory}
cohort_match = self.current_cohort_match(payload, accepted_payloads)
if cohort_match["classification"] == ThesisMatchClassification.DUPLICATE:
return {"decision": NoveltyGateDecision.REGENERATE_DUPLICATE, **cohort_match, "territory": territory}
if cohort_match["classification"] == ThesisMatchClassification.COMPETITIVE:
canonical = self.canonical_thesis_name(payload)
competitive_count = sum(1 for item in accepted_payloads if self.canonical_thesis_name(item) == canonical)
if competitive_count >= int(DEFAULT_DUPLICATE_POLICY["max_competitive_per_thesis_per_cohort"]):
return {"decision": NoveltyGateDecision.REGENERATE_DUPLICATE, **cohort_match, "territory": territory}
return {"decision": NoveltyGateDecision.ACCEPT, "classification": registry_match.get("classification", ThesisMatchClassification.NEW_THESIS), "similarity_score": registry_match.get("similarity_score", 0.0), "explanation": registry_match.get("explanation", "Accepted as novel enough for this cohort."), "territory": territory, "matched_thesis": thesis.id if thesis else None, "soft_exception": soft or ""}
def hard_exclusion_match(self, payload: dict[str, Any], exclusions: list[str]) -> str:
text = self.payload_text(payload)
canonical = self.canonical_thesis_name(payload).lower()
if "rfp" in text or "request for proposal" in text or "proposal drafting" in text:
return "AI RFP response drafting / proposal automation"
if "churn" in text or "retention playbook" in text or "save play" in text:
return "SaaS churn prediction / retention playbook generation"
for exclusion in exclusions:
if self._jaccard(exclusion, canonical + " " + text) >= 0.25:
return exclusion
return ""
def soft_exclusion_match(self, payload: dict[str, Any], exclusions: list[str]) -> str:
text = self.payload_text(payload)
checks = [("generic contract scanners", ["contract", "scanner"]), ("Shopify audit products", ["shopify", "audit"]), ("generic AI content generators", ["content", "generator"]), ("generic productized consulting", ["consulting"]), ("generic one-off audits", ["audit"]), ("generic chatbot wrappers", ["chatbot"])]
for label, words in checks:
if label in exclusions and all(word in text for word in words):
return label
return ""
def has_soft_exception(self, payload: dict[str, Any]) -> bool:
rationale = str(payload.get("exception_rationale", "") or payload.get("differentiation", "")).lower()
return any(word in rationale for word in ["vertical", "specific", "proprietary", "workflow", "recurring", "data", "platform", "compliance", "regulated"])
def match_portfolio_thesis(self, proposal: CompanyProposal) -> dict[str, Any]:
return self.match_payload_to_registry(self.payload_from_proposal(proposal))
def match_payload_to_registry(self, payload: dict[str, Any]) -> dict[str, Any]:
canonical = self.canonical_thesis_name(payload)
best = None
for thesis in PortfolioThesis.objects.all():
score = 1.0 if thesis.canonical_name == canonical else self._jaccard(self.payload_text(payload), " ".join([thesis.canonical_name, thesis.concise_thesis, thesis.icp, thesis.problem, thesis.offer, thesis.business_model, thesis.primary_channel]))
if best is None or score > best[0]:
best = (score, thesis)
if best is None or best[0] < 0.18:
return {"matched_thesis": None, "similarity_score": 0.0, "classification": ThesisMatchClassification.NEW_THESIS, "explanation": "No existing portfolio thesis was similar enough."}
score, thesis = best
classification = ThesisMatchClassification.DUPLICATE if score >= 0.9 else ThesisMatchClassification.NEAR_DUPLICATE if score >= 0.62 else ThesisMatchClassification.COMPETITIVE if score >= 0.38 else ThesisMatchClassification.ADJACENT
return {"matched_thesis": thesis, "similarity_score": round(score, 2), "classification": classification, "explanation": f"Matched {thesis.canonical_name} at similarity {score:.2f}."}
def current_cohort_match(self, payload: dict[str, Any], accepted_payloads: list[dict[str, Any]]) -> dict[str, Any]:
canonical = self.canonical_thesis_name(payload)
best_score = 0.0
best_name = ""
for accepted in accepted_payloads:
accepted_name = self.canonical_thesis_name(accepted)
if accepted_name == canonical:
return {"matched_thesis": None, "similarity_score": 1.0, "classification": ThesisMatchClassification.DUPLICATE, "explanation": f"Current cohort already accepted canonical thesis {accepted_name}."}
score = 1.0 if accepted_name == canonical else self._jaccard(self.payload_text(payload), self.payload_text(accepted))
if score > best_score:
best_score = score
best_name = accepted_name
classification = ThesisMatchClassification.NEW_THESIS
if best_score >= 0.78:
classification = ThesisMatchClassification.NEAR_DUPLICATE
elif best_score >= 0.55:
classification = ThesisMatchClassification.COMPETITIVE
elif best_score >= 0.18:
classification = ThesisMatchClassification.ADJACENT
return {"matched_thesis": None, "similarity_score": round(best_score, 2), "classification": classification, "explanation": f"Current cohort nearest thesis {best_name or 'none'} at similarity {best_score:.2f}."}
def create_portfolio_thesis_from_proposal(self, proposal: CompanyProposal, cohort: VentureCohort) -> PortfolioThesis:
fingerprint = self.fingerprint_proposal(proposal)
canonical = self.canonical_thesis_name(self.payload_from_proposal(proposal))
thesis, _ = PortfolioThesis.objects.update_or_create(
canonical_name=canonical,
defaults={"concise_thesis": proposal.pitch.get("One-line thesis", proposal.description), "category": str(proposal.metadata.get("search_territory", "")), "industry": fingerprint.industry, "icp": fingerprint.icp, "problem": fingerprint.problem, "offer": fingerprint.offer, "business_model": fingerprint.business_model, "primary_channel": fingerprint.primary_distribution_channel, "fingerprint": fingerprint.fingerprint_hash, "status": PortfolioThesisStatus.NEW, "first_seen_cohort": cohort, "last_seen_cohort": cohort, "metadata": {"created_from_proposal": str(proposal.id)}},
)
return thesis
def canonical_thesis_name(self, payload: dict[str, Any]) -> str:
text = self.payload_text(payload)
if "rfp" in text or "request for proposal" in text or "proposal drafting" in text:
return "AI RFP response automation for B2B SaaS"
if "churn" in text or "retention" in text or "save play" in text:
return "AI churn/retention intelligence for SaaS"
if "contract" in text and ("freelance" in text or "freelancer" in text):
return "Freelancer contract risk scanning"
if "shopify" in text and "audit" in text:
return "Shopify compliance/audit"
title = str(payload.get("title", "Untitled thesis")).strip()
return re.sub(r"\s+", " ", title)[:255]
def payload_text(self, payload: dict[str, Any]) -> str:
return " ".join(str(payload.get(key, "")) for key in ["title", "one_line_thesis", "description", "problem", "target_customer", "proposed_solution", "business_model", "pricing_hypothesis", "acquisition_strategy", "validation_plan", "differentiation"]).lower()
def payload_from_proposal(self, proposal: CompanyProposal) -> dict[str, Any]:
return {"title": proposal.title, "one_line_thesis": proposal.pitch.get("One-line thesis", ""), "description": proposal.description, "problem": proposal.problem, "target_customer": proposal.target_customer, "proposed_solution": proposal.proposed_solution, "business_model": proposal.business_model, "pricing_hypothesis": proposal.pricing_hypothesis, "acquisition_strategy": proposal.acquisition_strategy, "validation_plan": proposal.validation_plan, "differentiation": proposal.differentiation}
def diversity_metrics(self, cohort: VentureCohort) -> dict[str, Any]:
clusters = [cluster for cluster in cohort.thesis_clusters.select_related("portfolio_thesis", "proposal")]
proposals = [member.proposal for member in cohort.members.select_related("proposal")]
fingerprint_rows = [self.fingerprint_values(proposal) for proposal in proposals]
industries = Counter(row["industry"] for row in fingerprint_rows)
business_models = Counter(row["business_model"] for row in fingerprint_rows)
channels = Counter(row["primary_distribution_channel"] for row in fingerprint_rows)
thesis_counts = Counter(cluster.portfolio_thesis.canonical_name if cluster.portfolio_thesis else "unclustered" for cluster in clusters)
icp_groups = Counter(row["industry"] + ":" + row["price_band"] for row in fingerprint_rows)
total = max(1, len(proposals))
return {"unique_thesis_clusters": len(thesis_counts), "total_companies": len(proposals), "unique_thesis_clusters_ratio": round(len(thesis_counts) / total, 2), "unique_industries": len(industries), "unique_icp_groups": len(icp_groups), "unique_business_models": len(business_models), "unique_primary_channels": len(channels), "largest_thesis_cluster_size": max(thesis_counts.values()) if thesis_counts else 0, "largest_industry_concentration": max(industries.values()) if industries else 0, "largest_business_model_concentration": max(business_models.values()) if business_models else 0, "previous_qwen_baseline": {"companies": 10, "rfp_variants": 4, "churn_retention_variants": 5}, "thesis_counts": dict(thesis_counts), "industry_counts": dict(industries), "business_model_counts": dict(business_models), "primary_channel_counts": dict(channels)}
def fingerprint_values(self, proposal: CompanyProposal) -> dict[str, Any]:
try:
fingerprint = proposal.fingerprint
return {"industry": fingerprint.industry, "business_model": fingerprint.business_model, "primary_distribution_channel": fingerprint.primary_distribution_channel, "price_band": fingerprint.price_band}
except ObjectDoesNotExist:
return self._fingerprint_data(proposal)
def _company_payload(self, mandate: CompanyMandate, *, ideation_index: int | None = None, ideation_context: dict[str, Any] | None = None) -> tuple[dict[str, Any], str]:
if self.router is not None:
try:
slot = f" Independent cohort slot: {ideation_index}. Do not use or imitate other cohort ideas; no other ideas are visible." if ideation_index else ""
response = self.router.complete(ModelRequestContract(purpose=ModelCapability.PLANNING, model_hint=self.ideation_model_hint, prompt="Generate exactly ONE startup idea for Venture Discovery V0. Return a single JSON object, not a list. Respect no spend and no outreach in V0. Keep the $50 to $500 in 30 days mandate. Prefer, but do not require, AI-native businesses where Artifex can deliver most value using agents/local inference: AI wrapper platforms, AI-enabled services, AI infrastructure/developer tools, AI intelligence products, or SMB/enterprise workflow automation. Do not let AI novelty outweigh customer demand. Penalize generic audits, emergency fix services, one-off consulting, Shopify/ecommerce concentration, and identical outbound-led service models unless exceptional. Include title, one_line_thesis, description, problem, target_customer, proposed_solution, business_model, pricing_hypothesis, acquisition_strategy, validation_plan, capital_requested, time_to_first_dollar_estimate, expected_margin, build_complexity, market_evidence, differentiation, major_risks, confidence." + slot + " Mandate: " + json.dumps({"objective": mandate.objective, "constraints": mandate.constraints, "optimization_targets": mandate.optimization_targets, "ai_native_policy": AI_NATIVE_POLICY})))
context = ideation_context or {}
response = self.router.complete(ModelRequestContract(purpose=ModelCapability.PLANNING, model_hint=self.ideation_model_hint, prompt="Generate exactly ONE startup idea for Venture Discovery V0.3. Return a single JSON object, not a list. Respect no spend and no outreach in V0. Keep the $50 to $500 in 30 days mandate. Prefer AI-native businesses where Artifex can deliver most value using agents/local inference, but do not allow 'uses AI' to substitute for real customer pain. Prefer AI wrapper platforms, agentic workflow products, vertical copilots, AI-enabled services with low human labor, developer/AI infrastructure, intelligence products, automation products, service-to-platform paths, recurring revenue, and owned-compute leverage. DO NOT propose companies substantially equivalent to these hard-excluded thesis areas, including semantic variants: " + json.dumps(context.get("hard_exclusions", [])) + ". Soft exclusions require material differentiation and an explicit exception rationale: " + json.dumps(context.get("soft_exclusions", [])) + ". Search territory for this slot: " + str(context.get("territory", "OPEN_CATEGORY")) + ". This is a creative search constraint, not a fixed solution. Include title, one_line_thesis, description, problem, target_customer, proposed_solution, business_model, pricing_hypothesis, acquisition_strategy, validation_plan, capital_requested, time_to_first_dollar_estimate, expected_margin, build_complexity, market_evidence, differentiation, major_risks, confidence, and optional exception_rationale." + slot + " Mandate: " + json.dumps({"objective": mandate.objective, "constraints": mandate.constraints, "optimization_targets": mandate.optimization_targets, "ai_native_policy": AI_NATIVE_POLICY, "ideation_brief": context.get("brief", {})})))
parsed = extract_json_object(response.content)
if isinstance(parsed, dict) and parsed.get("title"):
return self._normalize_payload(parsed), self.ideation_model_hint
@ -496,6 +862,8 @@ class VentureDiscoveryService:
normalized = {key: payload.get(key, value) for key, value in fallback.items()}
normalized["market_evidence"] = self._as_list(normalized.get("market_evidence"))
normalized["major_risks"] = self._as_list(normalized.get("major_risks"))
if payload.get("exception_rationale"):
normalized["exception_rationale"] = payload["exception_rationale"]
return normalized
def _normalize_research(self, payload: dict[str, Any], required: list[str]) -> dict[str, Any]:
@ -725,7 +1093,10 @@ class VentureDiscoveryService:
ranking = "\n".join(f"{row['rank']}. {row['company']} - score {row['ic_score']}, P($500) {row['probability']}%, {row['decision']}" for row in content["rankings"])
top = "\n".join(f"- {row['company']}: {row['thesis']}" for row in content["top_3"])
demand = "\n".join(f"- {row['capability']}: {row['count']} companies, earliest {row['earliest_stage']}" for row in content["capability_demand"][:15])
return f"# Venture Discovery Cohort Report\n\nCohort: {content['cohort_id']}\nMandate: {content['mandate']}\nTotal spend: $0\nCustomer outreach: none\n\n## Ranking\n{ranking}\n\n## Top 3 Finalists\n{top}\n\n## Collisions\n{json.dumps(content['collisions'], indent=2)}\n\n## Portfolio Concentration\n{json.dumps(content['portfolio_concentration'], indent=2)}\n\n## Capability Demand\n{demand}\n\n## Recommended Artifex Build Priorities\n" + "\n".join(f"- {item}" for item in content["recommended_build_priorities"])
rejections = "\n".join(f"- Slot {row['slot_index']} attempt {row['attempt']}: {row['decision']} - {row['candidate_title']} ({row['reason']})" for row in content["generation_rejections"]) or "- none"
clusters = "\n".join(f"- {row['company']}: {row['portfolio_thesis']} ({row['classification']}, {row['similarity_score']})" for row in content["thesis_clusters"]) or "- none"
saturation = "\n".join(f"- {row['thesis']}: {row['status_recommendation']} ({row['proposal_count']} proposals, best {row['best_ic_score']})" for row in content["saturation_analysis"]) or "- none"
return f"# Venture Discovery Cohort Report\n\nCohort: {content['cohort_id']}\nMandate: {content['mandate']}\nTotal spend: $0\nCustomer outreach: none\n\n## Ideation Mandate\n{json.dumps(content['ideation_mandate'], indent=2)}\n\n## Hard Exclusions\n" + "\n".join(f"- {item}" for item in content["hard_exclusions"]) + "\n\n## Soft Exclusions\n" + "\n".join(f"- {item}" for item in content["soft_exclusions"]) + "\n\n## Search Territories\n" + "\n".join(f"- {item}" for item in content["search_territories"]) + f"\n\n## Generation Rejections\n{rejections}\n\n## Thesis Registry Before\n{json.dumps(content['thesis_registry_before'], indent=2)}\n\n## Thesis Registry After\n{json.dumps(content['thesis_registry_after'], indent=2)}\n\n## Thesis Clusters\n{clusters}\n\n## Saturation Analysis\n{saturation}\n\n## Idea Diversity Metrics\n{json.dumps(content['idea_diversity_metrics'], indent=2)}\n\n## Ranking\n{ranking}\n\n## Top 3 Finalists\n{top}\n\n## Collisions\n{json.dumps(content['collisions'], indent=2)}\n\n## Portfolio Concentration\n{json.dumps(content['portfolio_concentration'], indent=2)}\n\n## Capability Demand\n{demand}\n\n## Recommended Artifex Build Priorities\n" + "\n".join(f"- {item}" for item in content["recommended_build_priorities"])
def _artifact(self, proposal, mandate, artifact_type: str, name: str, content: dict[str, Any], readable: str, generated_by: str, *, graph_run=None) -> VentureArtifact:
return VentureArtifact.objects.create(proposal=proposal, mandate=mandate, graph_run=graph_run, artifact_type=artifact_type, name=name, content=content, readable=readable, generated_by=generated_by)

View file

@ -0,0 +1,284 @@
from __future__ import annotations
import json
import uuid
from pathlib import Path
from typing import Any
from django.core.exceptions import ObjectDoesNotExist
from django.core.management.base import BaseCommand, CommandError
from control_plane.ventures.models import VentureCohort
class Command(BaseCommand):
help = "Export a Venture Discovery cohort as Markdown or JSON."
def add_arguments(self, parser):
parser.add_argument("--cohort", help="Cohort stable ID or database primary key. Defaults to the latest cohort.")
parser.add_argument("--format", choices=["markdown", "json"], default="markdown")
parser.add_argument("--output", help="Path to write the export. Defaults to stdout.")
parser.add_argument("--include-sources", action="store_true", help="Include full market evidence/source entries in Markdown output.")
parser.add_argument("--max-sources", type=int, default=20, help="Maximum source entries per company when --include-sources is used.")
parser.add_argument("--indent", type=int, default=2, help="JSON indentation. Use 0 for compact JSON.")
def handle(self, *args, **options):
cohort = self.cohort(str(options.get("cohort") or ""))
payload = self.payload(cohort)
if options["format"] == "json":
indent = None if int(options["indent"]) <= 0 else int(options["indent"])
content = json.dumps(payload, indent=indent, default=str)
else:
content = self.markdown(payload, include_sources=bool(options["include_sources"]), max_sources=max(0, int(options["max_sources"])))
output = options.get("output")
if output:
path = Path(output)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content + "\n", encoding="utf-8")
self.stdout.write(self.style.SUCCESS(f"Exported cohort {cohort.cohort_id} to {path}"))
else:
self.stdout.write(content)
def cohort(self, identifier: str) -> VentureCohort:
if identifier:
cohort = VentureCohort.objects.filter(cohort_id=identifier).order_by("-created_at").first()
if cohort is None:
try:
cohort_pk = uuid.UUID(identifier)
except ValueError:
cohort_pk = None
if cohort_pk is not None:
cohort = VentureCohort.objects.filter(id=cohort_pk).first()
else:
cohort = VentureCohort.objects.order_by("-created_at").first()
if cohort is None:
raise CommandError("No Venture cohort found.")
return cohort
def payload(self, cohort: VentureCohort) -> dict[str, Any]:
members = list(cohort.members.select_related("proposal").order_by("rank", "created_at"))
proposals = [member.proposal for member in members]
report = cohort.mandate.artifacts.filter(artifact_type="VENTURE_DISCOVERY_COHORT_REPORT").order_by("-created_at").first()
return {
"cohort": {
"cohort_id": cohort.cohort_id,
"cohort_pk": str(cohort.id),
"status": cohort.status,
"size": cohort.cohort_size,
"members": len(members),
"concurrency": cohort.concurrency,
"metrics": cohort.metrics,
"metadata": cohort.metadata,
"graph_run_id": str(cohort.graph_run_id or ""),
"report_artifact_id": str(report.id) if report else None,
"fallback_count": sum(1 for proposal in proposals if proposal.metadata.get("generation_source") == "deterministic_fallback"),
"generation_sources": sorted({str(proposal.metadata.get("generation_source", "unknown")) for proposal in proposals}),
},
"companies": [self.company_payload(member) for member in members],
}
def company_payload(self, member) -> dict[str, Any]:
proposal = member.proposal
research = proposal.metadata.get("research", {}) if isinstance(proposal.metadata, dict) else {}
return {
"rank": member.rank,
"is_top_3": member.is_top_3,
"portfolio_score": member.portfolio_score,
"title": proposal.title,
"description": proposal.description,
"problem": proposal.problem,
"target_customer": proposal.target_customer,
"proposed_solution": proposal.proposed_solution,
"business_model": proposal.business_model,
"pricing_hypothesis": proposal.pricing_hypothesis,
"acquisition_strategy": proposal.acquisition_strategy,
"validation_plan": proposal.validation_plan,
"capital_requested": str(proposal.capital_requested),
"time_to_first_dollar_estimate": proposal.time_to_first_dollar_estimate,
"expected_margin": proposal.expected_margin,
"build_complexity": proposal.build_complexity,
"differentiation": proposal.differentiation,
"major_risks": proposal.major_risks,
"confidence": proposal.confidence,
"evidence_tier": proposal.evidence_tier,
"generation_source": proposal.metadata.get("generation_source") if isinstance(proposal.metadata, dict) else None,
"fallback_evidence": proposal.metadata.get("fallback_evidence") if isinstance(proposal.metadata, dict) else None,
"research": research,
"market_evidence": proposal.market_evidence,
"fingerprint": self.fingerprint_payload(proposal),
"decision": self.decision_payload(proposal),
}
def fingerprint_payload(self, proposal) -> dict[str, Any] | None:
try:
fingerprint = proposal.fingerprint
except ObjectDoesNotExist:
return None
return {
"industry": fingerprint.industry,
"icp": fingerprint.icp,
"problem": fingerprint.problem,
"offer": fingerprint.offer,
"business_model": fingerprint.business_model,
"primary_distribution_channel": fingerprint.primary_distribution_channel,
"price_band": fingerprint.price_band,
"time_to_first_cash_band": fingerprint.time_to_first_cash_band,
"required_capability_set": fingerprint.required_capability_set,
"geography_dependency": fingerprint.geography_dependency,
"regulatory_dependency": fingerprint.regulatory_dependency,
"online_offline": fingerprint.online_offline,
"service_software_hybrid": fingerprint.service_software_hybrid,
"fingerprint_hash": fingerprint.fingerprint_hash,
}
def decision_payload(self, proposal) -> dict[str, Any] | None:
diligence = proposal.ic_diligence.order_by("-created_at").first()
if diligence is None:
return None
try:
decision = diligence.decision
except ObjectDoesNotExist:
return None
return {
"decision": decision.decision,
"composite_score": decision.composite_score,
"probability_500_within_30_days": decision.probability_500_within_30_days,
"raw_probability_500_within_30_days": decision.raw_probability_500_within_30_days,
"evidence_ceiling": decision.evidence_ceiling,
"initial_tranche": str(decision.initial_tranche or ""),
"validation_condition": decision.validation_condition,
"component_scores": decision.component_scores,
"evidence_required": decision.evidence_required,
"kill_criteria": decision.kill_criteria,
"next_decision_point": decision.next_decision_point,
"probability_explanation": decision.probability_explanation,
}
def markdown(self, payload: dict[str, Any], *, include_sources: bool, max_sources: int) -> str:
cohort = payload["cohort"]
lines = [
f"# Venture Cohort Export: {cohort['cohort_id']}",
"",
"## Cohort Summary",
"",
f"Status: `{cohort['status']}`.",
f"Members: `{cohort['members']}`. Configured size: `{cohort['size']}`. Concurrency: `{cohort['concurrency']}`.",
f"Graph run: `{cohort['graph_run_id']}`. Report artifact: `{cohort['report_artifact_id']}`.",
f"Fallback count: `{cohort['fallback_count']}`. Generation sources: `{', '.join(cohort['generation_sources'])}`.",
"",
"Metrics:",
"",
"```json",
json.dumps(cohort["metrics"], indent=2, default=str),
"```",
"",
"## All Company Details",
"",
]
for company in payload["companies"]:
lines.extend(self.company_markdown(company, include_sources=include_sources, max_sources=max_sources))
return "\n".join(lines).rstrip()
def company_markdown(self, company: dict[str, Any], *, include_sources: bool, max_sources: int) -> list[str]:
decision = company.get("decision") or {}
research = company.get("research") or {}
lines = [
f"### Rank {company['rank']}: {company['title']}",
"",
"Portfolio score: "
f"`{company['portfolio_score']}`. IC decision: `{decision.get('decision', 'none')}`. "
f"Composite score: `{decision.get('composite_score', '')}`. "
f"P($500/30d): `{decision.get('probability_500_within_30_days', '')}%`. "
f"Raw P($500/30d): `{decision.get('raw_probability_500_within_30_days', '')}%`. "
f"Evidence ceiling: `{decision.get('evidence_ceiling', '')}%`. "
f"Evidence tier: `{company['evidence_tier']}`. Generation source: `{company.get('generation_source')}`. "
f"Fallback: `{str(company.get('fallback_evidence')).lower()}`.",
"",
]
for label, key in [
("Description", "description"),
("Problem", "problem"),
("Target customer", "target_customer"),
("Proposed solution", "proposed_solution"),
("Business model", "business_model"),
("Pricing hypothesis", "pricing_hypothesis"),
("Acquisition strategy", "acquisition_strategy"),
("Validation plan", "validation_plan"),
]:
lines.extend([f"{label}: {company.get(key, '')}", ""])
lines.extend(
[
f"Capital requested: `{company['capital_requested']}`. Time to first dollar: `{company['time_to_first_dollar_estimate']}`. Expected margin: `{company['expected_margin']}`. Build complexity: `{company['build_complexity']}`. Confidence: `{company['confidence']}`.",
"",
f"Differentiation: {company['differentiation']}",
"",
f"Major risks: {self.inline_list(company.get('major_risks', []))}",
"",
"Research: "
f"Coverage `{research.get('coverage_ratio', 0)}`; sources `{research.get('source_count', 0)}`; "
f"page fetches `{research.get('page_fetch_count', 0)}`; provider `{research.get('provider', 'none')}`; "
f"search provider `{research.get('search_provider', 'none')}`; unverified categories `{self.inline_list(research.get('unverified_categories', []))}`.",
"",
]
)
fingerprint = company.get("fingerprint") or {}
if fingerprint:
lines.extend(
[
"Fingerprint: "
f"industry `{fingerprint.get('industry')}`; business model `{fingerprint.get('business_model')}`; "
f"channel `{fingerprint.get('primary_distribution_channel')}`; price band `{fingerprint.get('price_band')}`; "
f"cash timing `{fingerprint.get('time_to_first_cash_band')}`; regulatory dependency `{fingerprint.get('regulatory_dependency')}`; "
f"hash `{fingerprint.get('fingerprint_hash')}`.",
"",
]
)
if decision:
lines.extend(
[
f"IC component scores: {self.score_list(decision.get('component_scores', {}))}",
"",
f"Validation condition: {decision.get('validation_condition', '')}",
"",
f"Evidence required: {self.inline_list(decision.get('evidence_required', []))}",
"",
f"Kill criteria: {self.inline_list(decision.get('kill_criteria', []))}",
"",
f"Next decision point: {decision.get('next_decision_point', '')}",
"",
f"Probability explanation: {decision.get('probability_explanation', '')}",
"",
]
)
if include_sources:
lines.extend(self.sources_markdown(company.get("market_evidence", []), max_sources=max_sources))
return lines
def sources_markdown(self, evidence: list[Any], *, max_sources: int) -> list[str]:
lines = ["Market evidence and sources:", ""]
if not evidence:
return [*lines, "No market evidence recorded.", ""]
for index, item in enumerate(evidence[:max_sources], start=1):
if isinstance(item, dict):
title = item.get("title") or item.get("type") or "source"
url = item.get("url", "")
summary = item.get("summary", "")
category = item.get("category", "")
lines.extend([f"Source {index}: {title}", f"Category: `{category}`. URL: `{url}`.", f"Summary: {summary}", ""])
else:
lines.extend([f"Evidence {index}: {item}", ""])
if len(evidence) > max_sources:
lines.extend([f"Additional evidence omitted: `{len(evidence) - max_sources}` entries.", ""])
return lines
def inline_list(self, value: Any) -> str:
if not value:
return "none"
if isinstance(value, list):
return "; ".join(str(item) for item in value) or "none"
return str(value)
def score_list(self, scores: dict[str, Any]) -> str:
if not scores:
return "none"
return "; ".join(f"{key} `{value}`" for key, value in scores.items())

View file

@ -29,30 +29,38 @@ class Command(BaseCommand):
def handle(self, *args, **options):
size = max(1, int(options["size"]))
concurrency = max(1, int(options["concurrency"]))
model_env_keys = ["ARTIFEX_VENTURE_IDEATION_MODEL", "ARTIFEX_VENTURE_RESEARCH_MODEL", "ARTIFEX_VENTURE_PORTFOLIO_IC_MODEL"]
previous_model_env = {key: os.environ.get(key) for key in model_env_keys}
if options["qwen_only"]:
os.environ["ARTIFEX_VENTURE_IDEATION_MODEL"] = "qwen"
os.environ["ARTIFEX_VENTURE_RESEARCH_MODEL"] = "qwen"
os.environ["ARTIFEX_VENTURE_PORTFOLIO_IC_MODEL"] = "qwen"
try:
providers = providers_from_resources()
if not providers:
raise CommandError("No model providers configured. Run seed_spark_resources first.")
if options["qwen_only"] and "qwen" not in providers:
raise CommandError("--qwen-only requested but no Qwen/local_inference provider is configured.")
providers = providers_from_resources()
if not providers:
raise CommandError("No model providers configured. Run seed_spark_resources first.")
if options["qwen_only"] and "qwen" not in providers:
raise CommandError("--qwen-only requested but no Qwen/local_inference provider is configured.")
version = champion_venture_discovery_cohort_graph_v1()
graph_run = GraphRun.objects.create(execution_graph_version=version, current_node=version.graph_spec["entry"])
service = VentureDiscoveryService(
ModelRouter(providers, persist_requests=bool(options["persist_requests"])),
web_research_available=not bool(options["no_web_research"]),
)
LangGraphRuntime(venture_discovery_cohort_registry(service, cohort_size=size, concurrency=concurrency)).run_until_terminal_or_paused(graph_run)
graph_run.refresh_from_db()
summary = self.summary(graph_run)
indent = None if int(options["indent"]) <= 0 else int(options["indent"])
self.stdout.write(json.dumps(summary, indent=indent, default=str))
if graph_run.status != GraphRunStatus.COMPLETE:
raise CommandError(f"Venture cohort run ended with status {graph_run.status}")
version = champion_venture_discovery_cohort_graph_v1()
graph_run = GraphRun.objects.create(execution_graph_version=version, current_node=version.graph_spec["entry"])
service = VentureDiscoveryService(
ModelRouter(providers, persist_requests=bool(options["persist_requests"])),
web_research_available=not bool(options["no_web_research"]),
)
LangGraphRuntime(venture_discovery_cohort_registry(service, cohort_size=size, concurrency=concurrency)).run_until_terminal_or_paused(graph_run)
graph_run.refresh_from_db()
summary = self.summary(graph_run)
indent = None if int(options["indent"]) <= 0 else int(options["indent"])
self.stdout.write(json.dumps(summary, indent=indent, default=str))
if graph_run.status != GraphRunStatus.COMPLETE:
raise CommandError(f"Venture cohort run ended with status {graph_run.status}")
finally:
for key, value in previous_model_env.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
def summary(self, graph_run: GraphRun) -> dict[str, object]:
summary: dict[str, object] = {

View file

@ -0,0 +1,124 @@
# Generated by Django 5.2.16 on 2026-08-16 09:22
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ventures', '0002_companyproposal_evidence_tier_and_more'),
]
operations = [
migrations.CreateModel(
name='CohortIdeationMandate',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('objective', models.TextField()),
('desired_opportunity_classes', models.JSONField(blank=True, default=list)),
('hard_exclusions', models.JSONField(blank=True, default=list)),
('soft_exclusions', models.JSONField(blank=True, default=list)),
('opportunity_territories', models.JSONField(blank=True, default=list)),
('portfolio_gaps', models.JSONField(blank=True, default=list)),
('diversity_preferences', models.JSONField(blank=True, default=dict)),
('registry_snapshot', models.JSONField(blank=True, default=dict)),
('created_by', models.CharField(default='Portfolio IC', max_length=120)),
('metadata', models.JSONField(blank=True, default=dict)),
('cohort', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='ideation_mandate', to='ventures.venturecohort')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='PortfolioThesis',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('canonical_name', models.CharField(max_length=255, unique=True)),
('concise_thesis', models.TextField()),
('category', models.CharField(blank=True, max_length=120)),
('industry', models.CharField(blank=True, max_length=160)),
('icp', models.TextField(blank=True)),
('problem', models.TextField(blank=True)),
('offer', models.TextField(blank=True)),
('business_model', models.CharField(blank=True, max_length=160)),
('primary_channel', models.CharField(blank=True, max_length=160)),
('ai_leverage', models.FloatField(default=0.0)),
('platformization_potential', models.FloatField(default=0.0)),
('fingerprint', models.CharField(blank=True, max_length=128)),
('status', models.CharField(choices=[('NEW', 'New'), ('ACTIVE_CANDIDATE', 'Active Candidate'), ('EXPLORED', 'Explored'), ('SATURATED', 'Saturated'), ('PAUSED', 'Paused'), ('REJECTED', 'Rejected'), ('OPERATING', 'Operating'), ('WINNER', 'Winner')], default='NEW', max_length=32)),
('proposal_count', models.PositiveIntegerField(default=0)),
('best_ic_score', models.FloatField(default=0.0)),
('metadata', models.JSONField(blank=True, default=dict)),
('best_company', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='best_for_portfolio_theses', to='ventures.companyproposal')),
('first_seen_cohort', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='first_seen_portfolio_theses', to='ventures.venturecohort')),
('last_seen_cohort', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='last_seen_portfolio_theses', to='ventures.venturecohort')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='PortfolioSaturationAnalysis',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('proposal_count', models.PositiveIntegerField(default=0)),
('best_ic_score', models.FloatField(default=0.0)),
('score_spread', models.FloatField(default=0.0)),
('status_recommendation', models.CharField(choices=[('NEW', 'New'), ('ACTIVE_CANDIDATE', 'Active Candidate'), ('EXPLORED', 'Explored'), ('SATURATED', 'Saturated'), ('PAUSED', 'Paused'), ('REJECTED', 'Rejected'), ('OPERATING', 'Operating'), ('WINNER', 'Winner')], max_length=32)),
('rationale', models.TextField(blank=True)),
('metadata', models.JSONField(blank=True, default=dict)),
('cohort', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='saturation_analyses', to='ventures.venturecohort')),
('portfolio_thesis', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='saturation_analyses', to='ventures.portfoliothesis')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='PortfolioThesisCluster',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('similarity_score', models.FloatField(default=0.0)),
('classification', models.CharField(choices=[('NEW_THESIS', 'New Thesis'), ('ADJACENT', 'Adjacent'), ('COMPETITIVE', 'Competitive'), ('NEAR_DUPLICATE', 'Near Duplicate'), ('DUPLICATE', 'Duplicate')], max_length=32)),
('explanation', models.TextField(blank=True)),
('metadata', models.JSONField(blank=True, default=dict)),
('cohort', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='thesis_clusters', to='ventures.venturecohort')),
('portfolio_thesis', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='proposal_clusters', to='ventures.portfoliothesis')),
('proposal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='portfolio_thesis_clusters', to='ventures.companyproposal')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='VentureGenerationRejection',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('slot_index', models.PositiveIntegerField()),
('attempt', models.PositiveIntegerField()),
('decision', models.CharField(choices=[('ACCEPT', 'Accept'), ('REGENERATE_HARD_EXCLUSION', 'Regenerate Hard Exclusion'), ('REGENERATE_DUPLICATE', 'Regenerate Duplicate'), ('REVIEW_SOFT_EXCLUSION', 'Review Soft Exclusion'), ('FAILED_IDEATION', 'Failed Ideation')], max_length=40)),
('reason', models.TextField(blank=True)),
('candidate', models.JSONField(blank=True, default=dict)),
('similarity_score', models.FloatField(default=0.0)),
('metadata', models.JSONField(blank=True, default=dict)),
('cohort', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='generation_rejections', to='ventures.venturecohort')),
('matched_thesis', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='generation_rejections', to='ventures.portfoliothesis')),
],
options={
'abstract': False,
},
),
]

View file

@ -53,6 +53,44 @@ class OverlapClassification(models.TextChoices):
DUPLICATE = "DUPLICATE"
class PortfolioThesisStatus(models.TextChoices):
NEW = "NEW"
ACTIVE_CANDIDATE = "ACTIVE_CANDIDATE"
EXPLORED = "EXPLORED"
SATURATED = "SATURATED"
PAUSED = "PAUSED"
REJECTED = "REJECTED"
OPERATING = "OPERATING"
WINNER = "WINNER"
class ThesisMatchClassification(models.TextChoices):
NEW_THESIS = "NEW_THESIS"
ADJACENT = "ADJACENT"
COMPETITIVE = "COMPETITIVE"
NEAR_DUPLICATE = "NEAR_DUPLICATE"
DUPLICATE = "DUPLICATE"
class NoveltyGateDecision(models.TextChoices):
ACCEPT = "ACCEPT"
REGENERATE_HARD_EXCLUSION = "REGENERATE_HARD_EXCLUSION"
REGENERATE_DUPLICATE = "REGENERATE_DUPLICATE"
REVIEW_SOFT_EXCLUSION = "REVIEW_SOFT_EXCLUSION"
FAILED_IDEATION = "FAILED_IDEATION"
class OpportunityTerritory(models.TextChoices):
VERTICAL_AI_WRAPPERS = "VERTICAL_AI_WRAPPERS"
ENTERPRISE_WORKFLOW_AUTOMATION = "ENTERPRISE_WORKFLOW_AUTOMATION"
SMB_AUTOMATION = "SMB_AUTOMATION"
DEVELOPER_AI_INFRASTRUCTURE = "DEVELOPER_AI_INFRASTRUCTURE"
INTELLIGENCE_MONITORING = "INTELLIGENCE_MONITORING"
DATA_DOCUMENT_AUTOMATION = "DATA_DOCUMENT_AUTOMATION"
AI_ENABLED_SERVICE_TO_PLATFORM = "AI_ENABLED_SERVICE_TO_PLATFORM"
OPEN_CATEGORY = "OPEN_CATEGORY"
class CompanyMandate(TimestampedModel):
objective = models.TextField()
constraints = models.JSONField(default=dict, blank=True)
@ -101,6 +139,75 @@ class CompanyProposal(TimestampedModel):
evidence_tier = models.CharField(max_length=40, choices=EvidenceTier.choices, default=EvidenceTier.TIER_0_THESIS)
class PortfolioThesis(TimestampedModel):
canonical_name = models.CharField(max_length=255, unique=True)
concise_thesis = models.TextField()
category = models.CharField(max_length=120, blank=True)
industry = models.CharField(max_length=160, blank=True)
icp = models.TextField(blank=True)
problem = models.TextField(blank=True)
offer = models.TextField(blank=True)
business_model = models.CharField(max_length=160, blank=True)
primary_channel = models.CharField(max_length=160, blank=True)
ai_leverage = models.FloatField(default=0.0)
platformization_potential = models.FloatField(default=0.0)
fingerprint = models.CharField(max_length=128, blank=True)
status = models.CharField(max_length=32, choices=PortfolioThesisStatus.choices, default=PortfolioThesisStatus.NEW)
proposal_count = models.PositiveIntegerField(default=0)
best_ic_score = models.FloatField(default=0.0)
best_company = models.ForeignKey(CompanyProposal, on_delete=models.SET_NULL, null=True, blank=True, related_name="best_for_portfolio_theses")
first_seen_cohort = models.ForeignKey("VentureCohort", on_delete=models.SET_NULL, null=True, blank=True, related_name="first_seen_portfolio_theses")
last_seen_cohort = models.ForeignKey("VentureCohort", on_delete=models.SET_NULL, null=True, blank=True, related_name="last_seen_portfolio_theses")
metadata = models.JSONField(default=dict, blank=True)
class CohortIdeationMandate(TimestampedModel):
cohort = models.OneToOneField("VentureCohort", on_delete=models.CASCADE, related_name="ideation_mandate")
objective = models.TextField()
desired_opportunity_classes = models.JSONField(default=list, blank=True)
hard_exclusions = models.JSONField(default=list, blank=True)
soft_exclusions = models.JSONField(default=list, blank=True)
opportunity_territories = models.JSONField(default=list, blank=True)
portfolio_gaps = models.JSONField(default=list, blank=True)
diversity_preferences = models.JSONField(default=dict, blank=True)
registry_snapshot = models.JSONField(default=dict, blank=True)
created_by = models.CharField(max_length=120, default="Portfolio IC")
metadata = models.JSONField(default=dict, blank=True)
class PortfolioThesisCluster(TimestampedModel):
cohort = models.ForeignKey("VentureCohort", on_delete=models.CASCADE, related_name="thesis_clusters")
proposal = models.ForeignKey(CompanyProposal, on_delete=models.CASCADE, related_name="portfolio_thesis_clusters")
portfolio_thesis = models.ForeignKey(PortfolioThesis, on_delete=models.SET_NULL, null=True, blank=True, related_name="proposal_clusters")
similarity_score = models.FloatField(default=0.0)
classification = models.CharField(max_length=32, choices=ThesisMatchClassification.choices)
explanation = models.TextField(blank=True)
metadata = models.JSONField(default=dict, blank=True)
class VentureGenerationRejection(TimestampedModel):
cohort = models.ForeignKey("VentureCohort", on_delete=models.CASCADE, related_name="generation_rejections")
slot_index = models.PositiveIntegerField()
attempt = models.PositiveIntegerField()
decision = models.CharField(max_length=40, choices=NoveltyGateDecision.choices)
reason = models.TextField(blank=True)
candidate = models.JSONField(default=dict, blank=True)
matched_thesis = models.ForeignKey(PortfolioThesis, on_delete=models.SET_NULL, null=True, blank=True, related_name="generation_rejections")
similarity_score = models.FloatField(default=0.0)
metadata = models.JSONField(default=dict, blank=True)
class PortfolioSaturationAnalysis(TimestampedModel):
cohort = models.ForeignKey("VentureCohort", on_delete=models.CASCADE, related_name="saturation_analyses")
portfolio_thesis = models.ForeignKey(PortfolioThesis, on_delete=models.CASCADE, related_name="saturation_analyses")
proposal_count = models.PositiveIntegerField(default=0)
best_ic_score = models.FloatField(default=0.0)
score_spread = models.FloatField(default=0.0)
status_recommendation = models.CharField(max_length=32, choices=PortfolioThesisStatus.choices)
rationale = models.TextField(blank=True)
metadata = models.JSONField(default=dict, blank=True)
class CompanyBoardReview(TimestampedModel):
proposal = models.ForeignKey(CompanyProposal, on_delete=models.CASCADE, related_name="board_reviews")
observations = models.JSONField(default=dict, blank=True)

View file

@ -0,0 +1,455 @@
# Venture Cohort Qwen Concurrency-2 Run
Date: 2026-08-16
## Purpose
Run a 10-company Venture Discovery cohort on Spark using Qwen-only model routing, capped at concurrency 2, after adding Qwen retry/probe support and a reusable Django management command.
## Commits
- `44c5ba4 Add Qwen retry and probe command`
- `526e280 Add Venture cohort run command`
## New Commands
Probe Qwen/local inference capacity:
```bash
DATABASE_URL=sqlite:///db.sqlite3 PATH=/home/daniel/Artifex/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin .venv/bin/python manage.py qwen_service_probe --requests 2 --concurrency 2 --token-budget 256
```
Run a Venture Discovery cohort:
```bash
DATABASE_URL=sqlite:///db.sqlite3 PATH=/home/daniel/Artifex/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin .venv/bin/python manage.py run_venture_cohort --size 10 --concurrency 2 --qwen-only --persist-requests
```
Useful flags:
- `--size`: number of companies to generate.
- `--concurrency`: bounded graph/service concurrency.
- `--qwen-only`: routes Venture ideation, research, and portfolio IC roles to Qwen.
- `--persist-requests`: persists sanitized model request telemetry.
- `--no-web-research`: disables SearXNG/page-fetch research.
- `--indent 0`: compact JSON output.
## Verification
Local targeted tests:
```bash
python -m pytest -q tests/test_model_router_providers.py tests/test_venture_discovery_cohort_v02.py
```
Result: `12 passed`.
Spark targeted provider tests:
```bash
PATH=/home/daniel/Artifex/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin PYTEST_ADDOPTS= .venv/bin/python -m pytest -p no:django -q tests/test_model_router_providers.py
```
Result: `6 passed`.
## Spark Qwen Probe
Before the cohort run:
```json
{
"before_health": "AVAILABLE",
"after_health": "AVAILABLE",
"requests": 2,
"concurrency": 2,
"completed": 2,
"failed": 0,
"runtime_seconds": 1.19
}
```
## Cohort Run Result
- Graph run: `15`
- Status: `COMPLETE`
- Cohort: `VDV02-20260816085736-69d4879b`
- Cohort primary key: `e2f736e1-a095-4a5c-931a-d4857c46d48c`
- Members: `10`
- Concurrency: `2`
- Peak concurrency: `2`
- Fallback count: `0`
- Generation sources: `qwen`
- Total sources: `22`
- Report artifact: `84ada259-8c89-4f5e-a680-b7c24a7c4220`
Metrics:
```json
{
"proposal_generation_runtime_seconds": 270.0,
"proposal_generation_peak_concurrency": 2,
"research_runtime_seconds": 133.62,
"total_sources": 22,
"public_research_queries": 10,
"research_peak_concurrency": 2,
"peak_concurrency": 2,
"individual_diligence_runtime_seconds": 3.8,
"individual_diligence_peak_concurrency": 2
}
```
## All 10 Company Details
### Rank 1: Contract Clause Risk Scanner for Freelancers
Portfolio score: `62.3`. IC decision: `REVISE_AND_RESUBMIT`. Composite score: `60.6`. P($500/30d): `55.0%`. Raw P($500/30d): `61.0%`. Evidence ceiling: `55.0%`. Evidence tier: `TIER_1_PUBLIC_EVIDENCE`. Generation source: `qwen`. Fallback: `false`.
Description: A lightweight web application where freelancers upload or paste contract text. Local LLM inference identifies high-risk clauses such as unlimited revisions, IP ownership ambiguity, and late payment penalties, then provides plain-English explanations and suggested counter-clauses. It is positioned as a pre-legal review tool, not a law firm.
Problem: Freelancers often sign contracts without understanding legal jargon, leading to unpaid work, IP disputes, and scope creep. Hiring a lawyer for every contract is too expensive, while ignoring contract risk is dangerous.
Target customer: Independent writers, designers, and developers billing `$50k-$150k` annually and signing `5-10` contracts per month.
Proposed solution: A SaaS scanner that ingests contract text, runs it through a local LLM workflow trained around common freelance contract pitfalls, and outputs a risk score, highlighted clauses, suggested edits, and a PDF report for negotiation.
Business model: Freemium SaaS with one free scan per month and a Pro tier for unlimited scans and priority support.
Pricing hypothesis: `$19/month` for Pro.
Acquisition strategy: Content-led SEO and community presence through articles such as `Top 10 Dangerous Freelance Clauses`, plus useful posts in freelance communities and forums. No cold outreach.
Validation plan: Build a static landing page with waitlist and sample risk report, publish educational content in three freelance forums or LinkedIn, measure waitlist conversion and email engagement, and proceed only if waitlist conversion exceeds `5%`.
Capital requested: `$0.00`. Time to first dollar: `14 days post-MVP launch`. Expected margin: `90%`. Build complexity: `Low`. Confidence: `0.73`.
Differentiation: Fast, cheap, single-purpose contract risk review focused on freelance contracts rather than general legal documents or full-suite business management.
Major risks: Legal liability from bad advice, freelancer price sensitivity, and competition from full-suite tools adding similar features.
Research: Coverage `80%`; sources `12`; page fetches `7`; provider `qwen`; search provider `searxng`; unverified category `regulatory_platform_risks`.
IC component scores: Demand Evidence `77`; Time-to-First-Dollar Attractiveness `50`; Capital Efficiency `82`; Validation Affordability `42`; Gross Margin Potential `85`; Distribution Feasibility `42`; Build Simplicity `42`; Defensibility `50`; Market Opportunity `62`; Competitive Position `68`; Risk Manageability `34`; AI Leverage `78`; Platformization Potential `82`; Probability of Reaching $500 `55`.
Validation condition: Obtain 5 credible target-customer responses or 1 explicit willingness-to-pay signal before any build or further spend.
Kill criteria: No credible responses after 10 targeted compliant conversations/posts once outreach is approved; no willingness-to-pay signal at `$49-$99`; customers only want free advice rather than a paid report.
### Rank 2: AI-Driven RFP Response Drafting Engine for Niche B2B SaaS
Portfolio score: `58.8`. IC decision: `CONDITIONAL_FUND`. Composite score: `65.1`. P($500/30d): `55.0%`. Raw P($500/30d): `69.0%`. Evidence ceiling: `55.0%`. Initial tranche: `$10.00`. Evidence tier: `TIER_1_PUBLIC_EVIDENCE`. Generation source: `qwen`. Fallback: `false`.
Description: A high-margin AI-enabled service that parses complex RFP documents, extracts requirements, maps them to client product documentation and prior winning bids, and generates structured compliant draft responses. The human-in-the-loop model handles final quality while AI performs parsing, mapping, and initial drafting.
Problem: Mid-market B2B SaaS companies with 50-500 employees face sales-cycle bottlenecks from lengthy RFP processes. Manual RFP responses take `2-4 weeks`, consume senior sales-engineering time, and create missed opportunities or reduced margins.
Target customer: VPs of Sales, Bid Managers, and Sales Engineers at B2B SaaS companies regularly responding to RFPs in categories such as cybersecurity, HR tech, and logistics software.
Proposed solution: An AI-agent workflow that ingests RFP PDF/Word files and a client knowledge base, extracts requirements, matches them to relevant content, and generates a structured draft with citations for client review.
Business model: Productized service with subscription path. Start with per-RFP fees for the first clients, then transition to monthly retainers for unlimited or priority drafts.
Pricing hypothesis: `$500` per RFP draft for first 10 clients; long-term target of `$2,000/month` for `5-10` RFPs/month.
Acquisition strategy: Content-led case studies in LinkedIn and niche B2B SaaS communities, plus a free RFP Complexity Score tool. No cold outreach in V0.
Validation plan: Build a minimal workflow with existing Artifex compute, create a landing page and demo, share in 3-5 communities, target 5-10 qualified conversations, and close 1-2 paid pilots within 30 days.
Capital requested: `$0.00`. Time to first dollar: `14-21 days`. Expected margin: `85-90%`. Build complexity: `Medium`. Confidence: `0.73`.
Differentiation: Privacy-first local inference, mid-market focus, and human-in-the-loop service quality rather than a generic chatbot or enterprise-only platform.
Major risks: AI hallucinations in compliance sections, resistance to AI-generated content, long B2B sales cycles, and competition from established RFP tools adding AI.
Research: Coverage `80%`; sources `7`; page fetches `3`; provider `qwen`; search provider `searxng`; unverified category `market_alternatives`.
IC component scores: Demand Evidence `77`; Time-to-First-Dollar Attractiveness `76`; Capital Efficiency `82`; Validation Affordability `42`; Gross Margin Potential `85`; Distribution Feasibility `60`; Build Simplicity `42`; Defensibility `60`; Market Opportunity `62`; Competitive Position `38`; Risk Manageability `72`; AI Leverage `78`; Platformization Potential `82`; Probability of Reaching $500 `55`.
Validation condition: Obtain 5 credible target-customer responses or 1 explicit willingness-to-pay signal before any build or further spend.
Kill criteria: No credible responses after 10 targeted compliant conversations/posts once outreach is approved; no willingness-to-pay signal at `$49-$99`; customers only want free advice rather than a paid report.
### Rank 3: AI-Powered RFP Response Drafting Engine for Niche B2B SaaS
Portfolio score: `52.3`. IC decision: `REVISE_AND_RESUBMIT`. Composite score: `60.2`. P($500/30d): `47.0%`. Raw P($500/30d): `47.0%`. Evidence ceiling: `55.0%`. Evidence tier: `TIER_1_PUBLIC_EVIDENCE`. Generation source: `qwen`. Fallback: `false`.
Description: A privacy-first RFP Copilot that connects to a company's product docs, case studies, and previous proposals, then parses uploaded RFP/RFQ requirements and generates a structured compliant draft. It focuses on repetitive security, compliance, and technical sections while highlighting strategic areas for human input.
Problem: Mid-market B2B SaaS companies spend `20-40` hours per RFP on manual drafting by senior sales engineers or product managers. Generic AI chatbots lack context and compliance rigor.
Target customer: VPs of Sales and Heads of Sales Operations at B2B SaaS companies with `$5M-$50M ARR` pursuing enterprise RFP deals.
Proposed solution: A RAG-based first-pass drafter over proprietary client data that outputs a Word/Docx file with tracked changes for human review.
Business model: Productized service transitioning to SaaS. Initially a flat monthly retainer for unlimited RFP drafts with human-in-the-loop review, later self-serve SaaS/API.
Pricing hypothesis: `$1,500/month` per sales-ops team seat.
Acquisition strategy: LinkedIn and RevOps community content around RFP efficiency. No cold outreach; inbound from pain-point content.
Validation plan: Build a landing page, publish LinkedIn posts targeting RevOps leaders, offer five free RFP audits using existing agents, and convert 1-2 leads to `$500` pilot fees.
Capital requested: `$0.00`. Time to first dollar: `14-21 days`. Expected margin: `90%+`. Build complexity: `Medium`. Confidence: `0.52`.
Differentiation: Privacy-first local inference, fast draft creation, lower cost than enterprise tools, and focus on drafting rather than full lifecycle RFP management.
Major risks: Data privacy concerns, hallucinations in compliance sections, and long sales cycles.
Research: Coverage `20%`; sources `3`; page fetches `3`; provider `qwen`; search provider `searxng`; unverified categories `pricing`, `customer_pain`, `market_alternatives`, `regulatory_platform_risks`.
IC component scores: Demand Evidence `45`; Time-to-First-Dollar Attractiveness `76`; Capital Efficiency `82`; Validation Affordability `78`; Gross Margin Potential `85`; Distribution Feasibility `60`; Build Simplicity `42`; Defensibility `52`; Market Opportunity `44`; Competitive Position `38`; Risk Manageability `34`; AI Leverage `78`; Platformization Potential `82`; Probability of Reaching $500 `47`.
Validation condition: Obtain 5 credible target-customer responses or 1 explicit willingness-to-pay signal before any build or further spend.
Kill criteria: No credible responses after 10 targeted compliant conversations/posts once outreach is approved; no willingness-to-pay signal at `$49-$99`; customers only want free advice rather than a paid report.
### Rank 4: AI-Driven RFP Response Drafting Service
Portfolio score: `45.2`. IC decision: `REVISE_AND_RESUBMIT`. Composite score: `54.5`. P($500/30d): `35.0%`. Raw P($500/30d): `43.0%`. Evidence ceiling: `35.0%`. Evidence tier: `TIER_0_THESIS`. Generation source: `qwen`. Fallback: `false`.
Description: A RFP Copilot service for B2B SaaS, IT services, and professional services companies. Clients upload an RFP and past winning proposals, and Artifex local inference extracts requirements, maps them to product features, and drafts a compliant response.
Problem: Mid-market B2B companies spend `20-40` hours per RFP, often with junior staff, causing missed deadlines, generic answers, and lost deals.
Target customer: VPs of Sales, Proposal Managers, and Sales Engineers at B2B SaaS and IT services companies with `$5M-$50M ARR`.
Proposed solution: Client uploads RFP and prior proposals, AI parses requirements and drafts in client voice, then client receives a 90% complete draft within 24 hours with optional human review.
Business model: Productized per-RFP service transitioning to monthly retainer for teams with high RFP volume.
Pricing hypothesis: `$500` per RFP draft or `$2,000/month` for 4 RFPs.
Acquisition strategy: Content-led case studies on reducing RFP time and referral partnerships with sales enablement consultants. No cold outreach in V0.
Validation plan: Create landing page, publish three LinkedIn posts, offer free RFP teardown, and target 5 qualified leads plus 1 paid pilot in 30 days.
Capital requested: `$0.00`. Time to first dollar: `14-21 days`. Expected margin: `85%`. Build complexity: `Low-Medium`. Confidence: `0.45`.
Differentiation: Faster and cheaper than agencies or enterprise tools, specialized for RFP structure and compliance rather than generic AI chat.
Major risks: Hallucinations in compliance sections, low inbound traffic, and client resistance to AI-generated content.
Research: Coverage `0%`; sources `0`; page fetches `0`; provider `none`; search provider `none`; all five research categories unverified.
IC component scores: Demand Evidence `25`; Time-to-First-Dollar Attractiveness `76`; Capital Efficiency `82`; Validation Affordability `42`; Gross Margin Potential `85`; Distribution Feasibility `60`; Build Simplicity `42`; Defensibility `52`; Market Opportunity `44`; Competitive Position `38`; Risk Manageability `34`; AI Leverage `66`; Platformization Potential `82`; Probability of Reaching $500 `35`.
Validation condition: Obtain 5 credible target-customer responses or 1 explicit willingness-to-pay signal before any build or further spend.
Kill criteria: No credible responses after 10 targeted compliant conversations/posts once outreach is approved; no willingness-to-pay signal at `$49-$99`; customers only want free advice rather than a paid report.
### Rank 5: Churn-Proof: AI-Driven Retention Playbook Generator for SaaS
Portfolio score: `45.1`. IC decision: `REVISE_AND_RESUBMIT`. Composite score: `55.4`. P($500/30d): `35.0%`. Raw P($500/30d): `43.0%`. Evidence ceiling: `35.0%`. Evidence tier: `TIER_0_THESIS`. Generation source: `qwen`. Fallback: `false`.
Description: An AI-enabled service that helps SaaS companies reduce churn by analyzing behavior patterns such as login frequency, feature adoption, and support tickets, then generating data-backed retention email copy and segmentation strategies. V0 is a service delivered with Artifex agents rather than a full SaaS platform.
Problem: Early-stage SaaS companies struggle with churn but lack data science resources to build retention models. They often rely on generic email blasts that fail to address usage gaps and pain points.
Target customer: Founders and Heads of Growth at B2B SaaS companies with 10-100 employees and `$50k-$500k MRR`.
Proposed solution: A Retention Sprint where the client provides anonymized usage data and churn metrics. Artifex agents identify at-risk segments and generate a three-part email sequence, subject lines, body copy, CTA recommendations, and segmented CSV output.
Business model: Service-as-a-product flat-fee Retention Sprint with future subscription Churn Monitor.
Pricing hypothesis: `$500` per Retention Sprint.
Acquisition strategy: Publish a synthetic-data case study on LinkedIn/Product Hunt and offer free Churn Health Checks to attract SaaS founders organically. No cold outreach.
Validation plan: Create a synthetic SaaS dataset, run agents to generate a retention playbook, publish the methodology as a clearly labeled V0 simulation, monitor engagement, and offer a first Retention Sprint to a willing participant.
Capital requested: `$50.00`. Time to first dollar: `14-21 days`. Expected margin: `95%`. Build complexity: `Low`. Confidence: `0.0`.
Differentiation: Focuses on the why behind churn, not generic marketing copy. AI-native workflow reduces delivery cost and time-to-value.
Major risks: Data privacy concerns, AI hallucination in generated copy, and market saturation from generic AI marketing tools.
Research: Coverage `0%`; sources `0`; page fetches `0`; provider `none`; search provider `none`; all five research categories unverified.
IC component scores: Demand Evidence `25`; Time-to-First-Dollar Attractiveness `76`; Capital Efficiency `82`; Validation Affordability `42`; Gross Margin Potential `85`; Distribution Feasibility `60`; Build Simplicity `42`; Defensibility `52`; Market Opportunity `44`; Competitive Position `38`; Risk Manageability `34`; AI Leverage `78`; Platformization Potential `82`; Probability of Reaching $500 `35`.
Validation condition: Obtain 5 credible target-customer responses or 1 explicit willingness-to-pay signal before any build or further spend.
Kill criteria: No credible responses after 10 targeted compliant conversations/posts once outreach is approved; no willingness-to-pay signal at `$49-$99`; customers only want free advice rather than a paid report.
### Rank 6: Churn-Proof: AI-Driven Customer Retention Playbook Generator
Portfolio score: `43.2`. IC decision: `REVISE_AND_RESUBMIT`. Composite score: `54.5`. P($500/30d): `35.0%`. Raw P($500/30d): `43.0%`. Evidence ceiling: `35.0%`. Evidence tier: `TIER_0_THESIS`. Generation source: `qwen`. Fallback: `false`.
Description: An AI-enabled service that analyzes SaaS customer health data including NPS, usage logs, and support tickets to identify churn risks and generate a customer success playbook. It turns weeks of manual analysis into a 24-hour deliverable.
Problem: SMB SaaS companies with 10-50 employees have churn problems but lack resources to identify at-risk segments. Enterprise tools are too expensive and consulting decks are too generic.
Target customer: Customer Success Managers and founders at B2B SaaS companies with 10-50 employees and `$1M-$5M ARR`.
Proposed solution: Clients upload anonymized CRM/support CSVs. Artifex agents cluster customers, identify churn predictors, and generate a PDF Retention Playbook with outreach scripts, feature recommendations, and pricing adjustments, plus a 15-minute review call.
Business model: Productized service with potential monthly monitoring or implementation support upsells.
Pricing hypothesis: `$500` per playbook.
Acquisition strategy: Publish anonymized case studies on LinkedIn/Product Hunt and engage SaaS communities by offering free mini-audits for testimonials. No cold outbound.
Validation plan: Build a demo agent using public data, create a landing page with Request a Sample CTA, share the sample in three SaaS communities, measure call/pre-order conversion, and target one pre-order or three strong intent signals.
Capital requested: `$0.00`. Time to first dollar: `14-21 days`. Expected margin: `90%+`. Build complexity: `Low`. Confidence: `0.45`.
Differentiation: Specific, data-driven, fast playbooks that are more actionable than generic dashboards or consulting reports.
Major risks: Data privacy concerns, weak sample-playbook conversion, and competition from generic AI chatbots.
Research: Coverage `0%`; sources `0`; page fetches `0`; provider `none`; search provider `none`; all five research categories unverified.
IC component scores: Demand Evidence `25`; Time-to-First-Dollar Attractiveness `76`; Capital Efficiency `82`; Validation Affordability `42`; Gross Margin Potential `85`; Distribution Feasibility `60`; Build Simplicity `42`; Defensibility `52`; Market Opportunity `44`; Competitive Position `38`; Risk Manageability `34`; AI Leverage `66`; Platformization Potential `82`; Probability of Reaching $500 `35`.
Validation condition: Obtain 5 credible target-customer responses or 1 explicit willingness-to-pay signal before any build or further spend.
Kill criteria: No credible responses after 10 targeted compliant conversations/posts once outreach is approved; no willingness-to-pay signal at `$49-$99`; customers only want free advice rather than a paid report.
### Rank 7: AI-Driven RFP Response Drafting Engine for Niche B2B SaaS
Portfolio score: `40.5`. IC decision: `REVISE_AND_RESUBMIT`. Composite score: `50.6`. P($500/30d): `35.0%`. Raw P($500/30d): `40.0%`. Evidence ceiling: `35.0%`. Evidence tier: `TIER_0_THESIS`. Generation source: `qwen`. Fallback: `false`.
Description: A RFP Copilot for B2B SaaS companies selling to enterprise or mid-market clients. The system ingests product docs, security whitepapers, and previous proposals, then maps new RFP requirements to existing content and drafts responses.
Problem: Mid-market B2B SaaS companies lose deals or waste engineering hours on RFPs. Sales engineers spend `10-20` hours copying, pasting, and editing responses, while generic AI lacks product and compliance context.
Target customer: Sales Engineers and Heads of Sales at B2B SaaS companies with `$5M-$50M ARR` pursuing deals requiring formal RFPs.
Proposed solution: A web interface for uploading a knowledge base and new RFPs. A RAG pipeline with local inference generates structured response documents with citations and confidence scores per section.
Business model: SaaS subscription with freemium tier and one-time setup fee.
Pricing hypothesis: Free for 1 RFP/month; Pro at `$299/month`; setup at `$500`.
Acquisition strategy: Content-led case studies, niche SaaS sales communities, a free RFP Audit lead magnet, and partnerships with sales enablement consultants.
Validation plan: Build MVP in 5 days, launch waitlist/demo page, use `$50` for highly targeted LinkedIn ads, book 10 qualified demos, run manual agent-assisted demos, and close 1-2 discounted pilots.
Capital requested: `$50.00`. Time to first dollar: `14 days`. Expected margin: `85%`. Build complexity: `Medium`. Confidence: `0.45`.
Differentiation: Privacy via local inference, specialized workflow rather than chatbot, SaaS-specific tuning, and RAG constraints for lower hallucination.
Major risks: Hallucinations in compliance sections, competition from larger RFP platforms adding AI, and adoption friction if setup is complex.
Research: Coverage `0%`; sources `0`; page fetches `0`; provider `none`; search provider `none`; all five research categories unverified.
IC component scores: Demand Evidence `25`; Time-to-First-Dollar Attractiveness `50`; Capital Efficiency `82`; Validation Affordability `42`; Gross Margin Potential `85`; Distribution Feasibility `42`; Build Simplicity `42`; Defensibility `42`; Market Opportunity `44`; Competitive Position `38`; Risk Manageability `34`; AI Leverage `78`; Platformization Potential `70`; Probability of Reaching $500 `35`.
Validation condition: Obtain 5 credible target-customer responses or 1 explicit willingness-to-pay signal before any build or further spend.
Kill criteria: No credible responses after 10 targeted compliant conversations/posts once outreach is approved; no willingness-to-pay signal at `$49-$99`; customers only want free advice rather than a paid report.
### Rank 8: Churn-Proof: AI-Powered Churn Risk Scoring for SaaS
Portfolio score: `38.6`. IC decision: `REVISE_AND_RESUBMIT`. Composite score: `53.6`. P($500/30d): `35.0%`. Raw P($500/30d): `43.0%`. Evidence ceiling: `35.0%`. Evidence tier: `TIER_0_THESIS`. Generation source: `qwen`. Fallback: `false`.
Description: An AI-enabled service that analyzes product usage logs, support tickets, and billing history to score churn probability and generate retention actions for at-risk accounts.
Problem: SaaS companies lose revenue to churn but lack data infrastructure or data science talent. Existing tools are too expensive or too dashboard-centric to produce actionable account-level insights.
Target customer: B2B SaaS companies with 10-100 employees and `$100k-$1M ARR` that have data but no dedicated data science team.
Proposed solution: A one-time Churn Risk Diagnostic where clients provide anonymized data, Artifex agents process it locally, train a lightweight churn model, and generate a PDF report with the top 20 at-risk accounts and specific retention actions within 48 hours.
Business model: Productized service with future subscription monitoring dashboard.
Pricing hypothesis: `$500` per diagnostic report.
Acquisition strategy: Content-led case studies using synthetic or public data, LinkedIn/Product Hunt posting, SaaS communities, and free mini-audits of available public data. No cold outreach.
Validation plan: Build a synthetic-data demo dashboard, create a landing page with Book a Demo CTA, share in SaaS communities, and use `$50` for a small LinkedIn ad boost targeting SaaS founders.
Capital requested: `$50.00`. Time to first dollar: `14-21 days`. Expected margin: `90%`. Build complexity: `Low`. Confidence: `0.45`.
Differentiation: Actionable AI-generated retention playbooks, local inference, and analysis of unstructured sources such as support tickets.
Major risks: Data privacy concerns, competition from BI tools, and first-customer acquisition difficulty.
Research: Coverage `0%`; sources `0`; page fetches `0`; provider `none`; search provider `none`; all five research categories unverified.
IC component scores: Demand Evidence `25`; Time-to-First-Dollar Attractiveness `76`; Capital Efficiency `82`; Validation Affordability `42`; Gross Margin Potential `85`; Distribution Feasibility `60`; Build Simplicity `42`; Defensibility `52`; Market Opportunity `44`; Competitive Position `38`; Risk Manageability `34`; AI Leverage `66`; Platformization Potential `70`; Probability of Reaching $500 `35`.
Validation condition: Obtain 5 credible target-customer responses or 1 explicit willingness-to-pay signal before any build or further spend.
Kill criteria: No credible responses after 10 targeted compliant conversations/posts once outreach is approved; no willingness-to-pay signal at `$49-$99`; customers only want free advice rather than a paid report.
### Rank 9: Churn-Proof: AI-Powered Churn Prediction & Save-Play Generator for SaaS
Portfolio score: `37.7`. IC decision: `REVISE_AND_RESUBMIT`. Composite score: `53.2`. P($500/30d): `35.0%`. Raw P($500/30d): `43.0%`. Evidence ceiling: `35.0%`. Evidence tier: `TIER_0_THESIS`. Generation source: `qwen`. Fallback: `false`.
Description: An AI-enabled service that connects to product analytics or CRM exports to identify at-risk accounts from usage decay, support sentiment, and payment behavior, then generates personalized save plays.
Problem: SaaS companies lose `5-10%` of MRR monthly to churn, and Customer Success teams often react after cancellation intent rather than proactively saving accounts.
Target customer: B2B SaaS companies with 10-100 employees, especially Customer Success Managers and Heads of CS responsible for retention.
Proposed solution: A weekly subscription service where clients provide anonymized usage/CRM data and Artifex agents flag top at-risk accounts with email drafts, in-app messages, or call scripts tailored to user behavior.
Business model: Productized monthly retainer for Churn Risk Reports and save-play generation. No software license initially.
Pricing hypothesis: `$500/month` per client.
Acquisition strategy: Targeted high-signal outreach to CS managers who recently posted about churn challenges, offering a free Churn Risk Snapshot in exchange for a 15-minute call. Positioned as personalized value-first outreach, not spam.
Validation plan: Build a data ingestion script and prompt chain, identify 20 SaaS CS managers, send 5 personalized messages, run 1-2 free snapshots, present the `$500/month` offer, close 1 client, and invoice after first report.
Capital requested: `$0.00`. Time to first dollar: `14-21 days`. Expected margin: `95%`. Build complexity: `Low`. Confidence: `0.45`.
Differentiation: Lightweight, high-touch service with immediate actionable value and no 6-month implementation; AI handles data analysis and copywriting.
Major risks: Data privacy concerns, low conversion from free snapshot to paid, and client churn.
Research: Coverage `0%`; sources `0`; page fetches `0`; provider `none`; search provider `none`; all five research categories unverified.
IC component scores: Demand Evidence `25`; Time-to-First-Dollar Attractiveness `76`; Capital Efficiency `82`; Validation Affordability `42`; Gross Margin Potential `85`; Distribution Feasibility `60`; Build Simplicity `42`; Defensibility `52`; Market Opportunity `44`; Competitive Position `38`; Risk Manageability `34`; AI Leverage `60`; Platformization Potential `70`; Probability of Reaching $500 `35`.
Validation condition: Obtain 5 credible target-customer responses or 1 explicit willingness-to-pay signal before any build or further spend.
Kill criteria: No credible responses after 10 targeted compliant conversations/posts once outreach is approved; no willingness-to-pay signal at `$49-$99`; customers only want free advice rather than a paid report.
### Rank 10: Churn-Proof: AI-Driven Retention Playbook Generator for SaaS
Portfolio score: `35.5`. IC decision: `REVISE_AND_RESUBMIT`. Composite score: `52.0`. P($500/30d): `35.0%`. Raw P($500/30d): `43.0%`. Evidence ceiling: `35.0%`. Evidence tier: `TIER_0_THESIS`. Generation source: `qwen`. Fallback: `false`.
Description: An AI-enabled service that analyzes customer usage logs, support tickets, and billing history to identify at-risk accounts and generate a Retention Playbook for CSMs. V0 is a productized service with AI doing most analysis and drafting, reviewed by Artifex.
Problem: SaaS companies have churn data but not enough time to produce personalized retention strategies. CSMs rely on gut feeling and generic templates.
Target customer: B2B SaaS companies with 10-100 employees and a dedicated Customer Success team, especially users of Intercom, Zendesk, Mixpanel, or Amplitude.
Proposed solution: Clients provide anonymized usage/support data. Artifex agents identify at-risk segments and produce a concise document with specific scripts and feature recommendations for the top 10 at-risk accounts.
Business model: One-time Retention Audit & Playbook package with low marginal cost from AI-assisted analysis.
Pricing hypothesis: `$500` per playbook.
Acquisition strategy: Content-led case study on LinkedIn/Product Hunt showing AI-identified churn risk, plus SaaS community engagement and free mini-audits for testimonials. No cold outreach.
Validation plan: Build a demo playbook using public or synthetic SaaS data, launch a landing page with Book a Demo CTA, post in LinkedIn and SaaS subreddits, target 5-10 inbound leads, and close 1-2 `$500` sales.
Capital requested: `$50.00`. Time to first dollar: `14 days`. Expected margin: `95%`. Build complexity: `Low`. Confidence: `0.45`.
Differentiation: Most churn tools show who is churning; this tells CSMs how to save them with specific scripts and strategies.
Major risks: Data privacy concerns and difficulty building initial trust.
Research: Coverage `0%`; sources `0`; page fetches `0`; provider `none`; search provider `none`; all five research categories unverified.
IC component scores: Demand Evidence `25`; Time-to-First-Dollar Attractiveness `76`; Capital Efficiency `82`; Validation Affordability `42`; Gross Margin Potential `85`; Distribution Feasibility `60`; Build Simplicity `42`; Defensibility `52`; Market Opportunity `44`; Competitive Position `38`; Risk Manageability `34`; AI Leverage `63`; Platformization Potential `50`; Probability of Reaching $500 `35`.
Validation condition: Obtain 5 credible target-customer responses or 1 explicit willingness-to-pay signal before any build or further spend.
Kill criteria: No credible responses after 10 targeted compliant conversations/posts once outreach is approved; no willingness-to-pay signal at `$49-$99`; customers only want free advice rather than a paid report.
## Post-Run Health
- Qwen: `AVAILABLE`
- GPT-5.6 Sol: `AVAILABLE`
- GPT-5.6 Terra: `AVAILABLE`
- GPT-5.6 Luna: `AVAILABLE`
- SearXNG Search: `AVAILABLE`
## Notes
- Qwen-only generation at concurrency 2 completed without deterministic fallback.
- vLLM logs showed no crash during the run.
- The top 3 still includes a near-duplicate RFP idea, so the next quality fix should be portfolio de-duplication or reranking before treating top 3 as final.

View file

@ -8,8 +8,8 @@ from graph.spec import ExecutionGraphSpec, GraphEdgeSpec, GraphNodeSpec
def venture_discovery_cohort_graph_v1() -> ExecutionGraphSpec:
nodes = ["prepare_cohort", "generate_independent_proposals", "initial_research", "fingerprint_theses", "collision_analysis", "run_individual_diligence", "portfolio_compare", "portfolio_ic", "aggregate_capabilities", "produce_cohort_report", "complete"]
spec = ExecutionGraphSpec(name="venture_discovery_cohort", version=1, graph_type="VENTURE_DISCOVERY_COHORT", entry="prepare_cohort", nodes={node: GraphNodeSpec(node, node if node == "complete" else f"venture_cohort_{node}") for node in nodes}, edges=[GraphEdgeSpec(nodes[index], nodes[index + 1], "success") for index in range(len(nodes) - 1)], terminal_nodes=["complete"], metadata={"description": "Venture Discovery V0.2: 10-company independent cohort, portfolio IC, collisions, capability demand, and report."})
nodes = ["prepare_cohort", "portfolio_thesis_review", "create_ideation_mandate", "allocate_search_territories", "generate_independent_proposals", "novelty_gate", "initial_research", "fingerprint_theses", "collision_analysis", "cluster_theses", "run_individual_diligence", "portfolio_compare", "portfolio_ic", "saturation_analysis", "update_thesis_registry", "aggregate_capabilities", "produce_cohort_report", "complete"]
spec = ExecutionGraphSpec(name="venture_discovery_cohort", version=2, graph_type="VENTURE_DISCOVERY_COHORT", entry="prepare_cohort", nodes={node: GraphNodeSpec(node, node if node == "complete" else f"venture_cohort_{node}") for node in nodes}, edges=[GraphEdgeSpec(nodes[index], nodes[index + 1], "success") for index in range(len(nodes) - 1)], terminal_nodes=["complete"], metadata={"description": "Venture Discovery V0.3: portfolio thesis registry, ideation mandate, novelty gates, saturation analysis, and cohort IC."})
spec.validate()
return spec
@ -48,6 +48,30 @@ class GenerateIndependentNode(CohortNode):
return NodeResult("COMPLETE", "success", {"proposal_count": len(proposals), "independent_generation": True})
class PortfolioThesisReviewNode(CohortNode):
def run(self, context: GraphExecutionContext) -> NodeResult:
review = self.service.portfolio_thesis_review(self.cohort(context))
return NodeResult("COMPLETE", "success", {"registry_size": review["registry_size"], "saturated_count": len(review["saturated_thesis_areas"])})
class IdeationMandateNode(CohortNode):
def run(self, context: GraphExecutionContext) -> NodeResult:
mandate = self.service.create_ideation_mandate(self.cohort(context))
return NodeResult("COMPLETE", "success", {"mandate_id": str(mandate.id), "territory_count": len(mandate.opportunity_territories)})
class SearchTerritoryNode(CohortNode):
def run(self, context: GraphExecutionContext) -> NodeResult:
territories = self.service.allocate_search_territories(self.cohort(context))
return NodeResult("COMPLETE", "success", {"territories": territories})
class NoveltyGateNode(CohortNode):
def run(self, context: GraphExecutionContext) -> NodeResult:
cohort = self.cohort(context)
return NodeResult("COMPLETE", "success", {"accepted_proposals": cohort.metrics.get("accepted_proposals", cohort.members.count()), "rejections": cohort.generation_rejections.count(), "failed_slots": cohort.metrics.get("failed_slots", 0)})
class ResearchNode(CohortNode):
def run(self, context: GraphExecutionContext) -> NodeResult:
cohort = self.cohort(context)
@ -68,6 +92,12 @@ class CollisionNode(CohortNode):
return NodeResult("COMPLETE", "success", {"pair_count": len(collisions)})
class ClusterThesesNode(CohortNode):
def run(self, context: GraphExecutionContext) -> NodeResult:
clusters = self.service.cluster_theses(self.cohort(context))
return NodeResult("COMPLETE", "success", {"cluster_count": len(clusters)})
class DiligenceNode(CohortNode):
def run(self, context: GraphExecutionContext) -> NodeResult:
cohort = self.cohort(context)
@ -89,6 +119,18 @@ class CapabilityNode(CohortNode):
return NodeResult("COMPLETE", "success", {"capability_count": len(demand), "top_3_gap_count": len(top_3)})
class SaturationNode(CohortNode):
def run(self, context: GraphExecutionContext) -> NodeResult:
rows = self.service.saturation_analysis(self.cohort(context))
return NodeResult("COMPLETE", "success", {"analysis_count": len(rows)})
class RegistryUpdateNode(CohortNode):
def run(self, context: GraphExecutionContext) -> NodeResult:
updates = self.service.update_thesis_registry(self.cohort(context))
return NodeResult("COMPLETE", "success", {"update_count": len(updates)})
class ReportNode(CohortNode):
def run(self, context: GraphExecutionContext) -> NodeResult:
artifact = self.service.produce_cohort_report(self.cohort(context))
@ -102,6 +144,6 @@ class NoopNode(CohortNode):
def venture_discovery_cohort_registry(service: VentureDiscoveryService, *, cohort_size: int = 10, concurrency: int = 1) -> NodeHandlerRegistry:
registry = NodeHandlerRegistry()
for handler in [PrepareCohortNode(service, "venture_cohort_prepare_cohort", cohort_size=cohort_size, concurrency=concurrency), GenerateIndependentNode(service, "venture_cohort_generate_independent_proposals"), ResearchNode(service, "venture_cohort_initial_research"), FingerprintNode(service, "venture_cohort_fingerprint_theses"), CollisionNode(service, "venture_cohort_collision_analysis"), DiligenceNode(service, "venture_cohort_run_individual_diligence"), NoopNode(service, "venture_cohort_portfolio_compare"), PortfolioNode(service, "venture_cohort_portfolio_ic"), CapabilityNode(service, "venture_cohort_aggregate_capabilities"), ReportNode(service, "venture_cohort_produce_cohort_report")]:
for handler in [PrepareCohortNode(service, "venture_cohort_prepare_cohort", cohort_size=cohort_size, concurrency=concurrency), PortfolioThesisReviewNode(service, "venture_cohort_portfolio_thesis_review"), IdeationMandateNode(service, "venture_cohort_create_ideation_mandate"), SearchTerritoryNode(service, "venture_cohort_allocate_search_territories"), GenerateIndependentNode(service, "venture_cohort_generate_independent_proposals"), NoveltyGateNode(service, "venture_cohort_novelty_gate"), ResearchNode(service, "venture_cohort_initial_research"), FingerprintNode(service, "venture_cohort_fingerprint_theses"), CollisionNode(service, "venture_cohort_collision_analysis"), ClusterThesesNode(service, "venture_cohort_cluster_theses"), DiligenceNode(service, "venture_cohort_run_individual_diligence"), NoopNode(service, "venture_cohort_portfolio_compare"), PortfolioNode(service, "venture_cohort_portfolio_ic"), SaturationNode(service, "venture_cohort_saturation_analysis"), RegistryUpdateNode(service, "venture_cohort_update_thesis_registry"), CapabilityNode(service, "venture_cohort_aggregate_capabilities"), ReportNode(service, "venture_cohort_produce_cohort_report")]:
registry.register(handler)
return registry

View file

@ -7,8 +7,8 @@ from io import StringIO
from django.core.management import call_command
from agents.venture_discovery import EVIDENCE_CEILINGS, SCORE_DEFINITIONS, SCORE_DIMENSIONS, VentureDiscoveryService
from control_plane.ventures.models import CompanyProposal, EvidenceTier, OverlapClassification, PortfolioICReview, VentureCapabilityDemand, VentureCohort, VentureCollision, VentureThesisFingerprint
from agents.venture_discovery import DEFAULT_HARD_EXCLUSIONS, EVIDENCE_CEILINGS, SCORE_DEFINITIONS, SCORE_DIMENSIONS, VentureDiscoveryService
from control_plane.ventures.models import CohortIdeationMandate, CompanyProposal, EvidenceTier, NoveltyGateDecision, OpportunityTerritory, OverlapClassification, PortfolioICReview, PortfolioSaturationAnalysis, PortfolioThesis, PortfolioThesisCluster, PortfolioThesisStatus, VentureCapabilityDemand, VentureCohort, VentureCollision, VentureGenerationRejection, VentureThesisFingerprint
from graph.bootstrap import champion_venture_discovery_cohort_graph_v1
from graph.langgraph_runtime import LangGraphRuntime
from graph.models import GraphRun, GraphRunStatus
@ -32,15 +32,30 @@ class SequenceProvider(ModelProvider):
n = self.company_calls
industries = ["Shopify", "Developer", "Legal", "Healthcare", "Real Estate", "Restaurant", "Security", "Education", "Logistics", "Finance"]
industry = industries[(n - 1) % len(industries)]
return ModelResponseContract("sol", json.dumps({"title": f"{industry} Validation Offer {n}", "one_line_thesis": f"Sell a productized {industry.lower()} audit to a narrow buyer before building software.", "description": f"A fixed-scope {industry} audit.", "problem": f"{industry} buyers have urgent operational gaps.", "target_customer": f"Small {industry} operators with active revenue.", "proposed_solution": f"Manual {industry} audit with prioritized fixes.", "business_model": "Productized service", "pricing_hypothesis": "$99 audit", "acquisition_strategy": "Compliant community posts and direct referrals after approval", "validation_plan": "Collect 5 credible target-customer responses or 1 willingness-to-pay signal before build/spend.", "capital_requested": "50", "time_to_first_dollar_estimate": "3-7 days after outreach approval", "expected_margin": "80-90% gross margin", "build_complexity": "LOW", "market_evidence": [], "differentiation": "Fast narrow audit", "major_risks": ["Demand unproven"], "confidence": 0.65}), {})
return ModelResponseContract("sol", json.dumps({"title": f"{industry} Workflow Monitor {n}", "one_line_thesis": f"Sell a recurring {industry.lower()} workflow monitor to a narrow buyer before building broad software.", "description": f"A focused {industry} workflow monitoring product with AI-assisted weekly exception reports.", "problem": f"{industry} buyers miss recurring operational exceptions that cost time or revenue.", "target_customer": f"Small {industry} operators with active revenue.", "proposed_solution": f"AI-assisted {industry} workflow monitor with prioritized exception reports and lightweight automation.", "business_model": "Recurring AI-enabled service with software automation", "pricing_hypothesis": "$99/month monitor", "acquisition_strategy": "Compliant community posts and direct referrals after approval", "validation_plan": "Collect 5 credible target-customer responses or 1 willingness-to-pay signal before build/spend.", "capital_requested": "50", "time_to_first_dollar_estimate": "3-7 days after outreach approval", "expected_margin": "80-90% gross margin", "build_complexity": "LOW", "market_evidence": [], "differentiation": "Specific recurring workflow data and AI-assisted exception monitoring", "exception_rationale": "vertical specific recurring workflow with proprietary data", "major_risks": ["Demand unproven"], "confidence": 0.65}), {})
def health(self) -> str:
return "AVAILABLE"
class RejectThenAcceptProvider(SequenceProvider):
def complete(self, request: ModelRequestContract) -> ModelResponseContract:
if "Bounded public web market research" in request.prompt:
return super().complete(request)
self.company_calls += 1
if self.company_calls == 1:
return ModelResponseContract("sol", json.dumps({"title": "RFP Copilot", "one_line_thesis": "AI RFP response drafting for SaaS teams.", "description": "Automates request for proposal answers.", "problem": "Sales teams hate RFPs.", "target_customer": "B2B SaaS sales teams", "proposed_solution": "Proposal drafting automation", "business_model": "SaaS", "pricing_hypothesis": "$99/month", "acquisition_strategy": "Content", "validation_plan": "Interview later", "capital_requested": "50", "time_to_first_dollar_estimate": "7 days", "expected_margin": "90%", "build_complexity": "LOW", "market_evidence": [], "differentiation": "AI", "major_risks": ["Excluded"], "confidence": 0.5}), {})
return super().complete(request)
def service() -> VentureDiscoveryService:
provider = SequenceProvider()
return VentureDiscoveryService(ModelRouter({"sol": provider, "luna": provider}), web_research_available=True)
return VentureDiscoveryService(ModelRouter({"sol": provider, "luna": provider}), web_research_available=True, ideation_model_hint="sol", research_model_hint="luna")
def rejecting_service() -> VentureDiscoveryService:
provider = RejectThenAcceptProvider()
return VentureDiscoveryService(ModelRouter({"sol": provider, "luna": provider}), web_research_available=True, ideation_model_hint="sol", research_model_hint="luna")
def test_bounded_map_runs_concurrently_and_preserves_result_order() -> None:
@ -155,6 +170,54 @@ def test_cohort_size_ranking_top3_capability_aggregation_and_graph_lineage() ->
assert len(report.content["rankings"]) == 10
assert len(report.content["top_3"]) == 3
assert report.content["total_spend"] == 0
assert CohortIdeationMandate.objects.filter(cohort=cohort).exists()
assert PortfolioThesisCluster.objects.filter(cohort=cohort).count() == 10
assert PortfolioSaturationAnalysis.objects.filter(cohort=cohort).exists()
assert "ideation_mandate" in report.content
assert "thesis_registry_before" in report.content
assert "thesis_registry_after" in report.content
assert "saturation_analysis" in report.content
assert "idea_diversity_metrics" in report.content
def test_v03_thesis_registry_mandate_and_territory_allocation() -> None:
svc = service()
cohort = svc.prepare_cohort(size=10)
review = svc.portfolio_thesis_review(cohort)
mandate = svc.create_ideation_mandate(cohort)
assert PortfolioThesis.objects.filter(canonical_name="AI RFP response automation for B2B SaaS", status=PortfolioThesisStatus.SATURATED).exists()
assert "AI RFP response automation for B2B SaaS" in [row["canonical_name"] for row in review["saturated_thesis_areas"]]
assert mandate.hard_exclusions == DEFAULT_HARD_EXCLUSIONS
assert mandate.opportunity_territories[:2] == [OpportunityTerritory.VERTICAL_AI_WRAPPERS, OpportunityTerritory.VERTICAL_AI_WRAPPERS]
assert mandate.opportunity_territories[-2:] == [OpportunityTerritory.OPEN_CATEGORY, OpportunityTerritory.OPEN_CATEGORY]
def test_v03_novelty_gate_blocks_hard_exclusion_and_allows_soft_exception() -> None:
svc = service()
cohort = svc.prepare_cohort(size=2)
mandate = svc.create_ideation_mandate(cohort)
hard_payload = {"title": "RFP Copilot", "one_line_thesis": "AI RFP response drafting", "description": "Drafts proposal responses", "problem": "RFPs", "target_customer": "SaaS sales", "proposed_solution": "RFP automation", "business_model": "SaaS", "pricing_hypothesis": "$99", "acquisition_strategy": "content", "validation_plan": "test", "differentiation": "AI"}
soft_payload = {**hard_payload, "title": "Regulated Contract Workflow Monitor", "one_line_thesis": "Contract scanner for regulated vendor workflows", "description": "Contract scanner with proprietary recurring workflow data", "problem": "Regulated teams miss renewal obligations", "target_customer": "Compliance teams", "proposed_solution": "Vertical contract workflow monitor", "differentiation": "Vertical proprietary workflow data", "exception_rationale": "specific regulated workflow with recurring data"}
assert svc.novelty_gate(hard_payload, cohort, [], mandate, territory="OPEN_CATEGORY")["decision"] == NoveltyGateDecision.REGENERATE_HARD_EXCLUSION
assert svc.novelty_gate(soft_payload, cohort, [], mandate, territory="DATA_DOCUMENT_AUTOMATION")["decision"] == NoveltyGateDecision.ACCEPT
def test_v03_generation_regenerates_rejected_slots_and_preserves_size() -> None:
svc = rejecting_service()
cohort = svc.prepare_cohort(size=3)
svc.portfolio_thesis_review(cohort)
svc.create_ideation_mandate(cohort)
proposals = svc.generate_independent_proposals(cohort)
assert len(proposals) == 3
assert cohort.members.count() == 3
assert VentureGenerationRejection.objects.filter(cohort=cohort, decision=NoveltyGateDecision.REGENERATE_HARD_EXCLUSION).count() == 1
assert cohort.metrics["hard_exclusion_rejections"] == 1
assert cohort.metrics["failed_slots"] == 0
def test_run_venture_cohort_management_command_outputs_summary(monkeypatch) -> None:
@ -176,3 +239,26 @@ def test_run_venture_cohort_management_command_outputs_summary(monkeypatch) -> N
assert summary["generation_sources"] == ["qwen"]
assert len(summary["top_3"]) == 3
assert cohort.members.count() == 3
def test_export_venture_cohort_management_command_outputs_all_companies(tmp_path) -> None:
svc = service()
version = champion_venture_discovery_cohort_graph_v1()
graph_run = GraphRun.objects.create(execution_graph_version=version, current_node=version.graph_spec["entry"])
LangGraphRuntime(venture_discovery_cohort_registry(svc, cohort_size=3, concurrency=1)).run_until_terminal_or_paused(graph_run)
cohort = VentureCohort.objects.get(id=graph_run.metadata["cohort_id"])
markdown_path = tmp_path / "cohort.md"
json_path = tmp_path / "cohort.json"
call_command("export_venture_cohort", "--cohort", cohort.cohort_id, "--output", str(markdown_path))
call_command("export_venture_cohort", "--cohort", cohort.cohort_id, "--format", "json", "--output", str(json_path))
markdown = markdown_path.read_text(encoding="utf-8")
payload = json.loads(json_path.read_text(encoding="utf-8"))
assert "## All Company Details" in markdown
assert markdown.count("### Rank") == 3
assert "Validation condition:" in markdown
assert payload["cohort"]["cohort_id"] == cohort.cohort_id
assert len(payload["companies"]) == 3
assert payload["companies"][0]["decision"]["decision"]