Skip to content

Commit ba4b27d

Browse files
feat(frontend): Refine Gap Analysis link strength categorization (Weak threshold 20 -> 7) (#717)
* Adjust gap analysis strength categorization for weak links * feat: implement feature toggle GAP_ANALYSIS_OPTIMIZED per feedback * style: fix black formatting in db.py
1 parent c98eb2c commit ba4b27d

File tree

3 files changed

+176
-3
lines changed

3 files changed

+176
-3
lines changed

application/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ class Config:
88
SQLALCHEMY_RECORD_QUERIES = False
99
ITEMS_PER_PAGE = 20
1010
SLOW_DB_QUERY_TIME = 0.5
11+
# Feature toggle for gap analysis optimization (default: False for safety)
12+
GAP_ANALYSIS_OPTIMIZED = (
13+
os.environ.get("GAP_ANALYSIS_OPTIMIZED", "False").lower() == "true"
14+
)
1115

1216

1317
class DevelopmentConfig(Config):

application/database/db.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,175 @@ def link_CRE_to_Node(self, CRE_id, node_id, link_type):
561561
raise Exception(f"Unknown relation type {link_type} for Nodes to CREs")
562562

563563
@classmethod
564+
def gap_analysis(self, name_1, name_2):
565+
"""
566+
Gap analysis with feature toggle support.
567+
568+
Toggle between original exhaustive traversal (default) and
569+
optimized tiered pruning (opt-in via GAP_ANALYSIS_OPTIMIZED env var).
570+
"""
571+
from application.config import Config
572+
573+
if Config.GAP_ANALYSIS_OPTIMIZED:
574+
logger.info(
575+
f"Gap Analysis: Using OPTIMIZED tiered pruning for {name_1}>>{name_2}"
576+
)
577+
return self._gap_analysis_optimized(name_1, name_2)
578+
else:
579+
logger.info(
580+
f"Gap Analysis: Using ORIGINAL exhaustive traversal for {name_1}>>{name_2}"
581+
)
582+
return self._gap_analysis_original(name_1, name_2)
583+
584+
@classmethod
585+
def _gap_analysis_optimized(self, name_1, name_2):
586+
"""
587+
OPTIMIZED: Tiered Pruning Strategy with Early Exit
588+
589+
Tier 1: Strong links only (LINKED_TO, SAME, AUTOMATICALLY_LINKED_TO)
590+
Tier 2: Add hierarchical (CONTAINS) if Tier 1 empty
591+
Tier 3: Fallback to wildcard if both tiers empty
592+
"""
593+
logger.info(
594+
f"Performing OPTIMIZED GraphDB queries for gap analysis {name_1}>>{name_2}"
595+
)
596+
base_standard = NeoStandard.nodes.filter(name=name_1)
597+
denylist = ["Cross-cutting concerns"]
598+
599+
# Tier 1: Strong Links (LINKED_TO, SAME, AUTOMATICALLY_LINKED_TO)
600+
path_records, _ = db.cypher_query(
601+
"""
602+
MATCH (BaseStandard:NeoStandard {name: $name1})
603+
MATCH (CompareStandard:NeoStandard {name: $name2})
604+
MATCH p = allShortestPaths((BaseStandard)-[:(LINKED_TO|AUTOMATICALLY_LINKED_TO|SAME)*..20]-(CompareStandard))
605+
WITH p
606+
WHERE length(p) > 1 AND ALL(n in NODES(p) WHERE (n:NeoCRE or n = BaseStandard or n = CompareStandard) AND NOT n.name in $denylist)
607+
RETURN p
608+
""",
609+
{"name1": name_1, "name2": name_2, "denylist": denylist},
610+
resolve_objects=True,
611+
)
612+
613+
# If strict strong links found, return early (Pruning)
614+
if path_records and len(path_records) > 0:
615+
logger.info(
616+
f"Gap Analysis: Tier 1 (Strong) found {len(path_records)} paths. Pruning remainder."
617+
)
618+
return self._format_gap_analysis_response(base_standard, path_records)
619+
620+
# Tier 2: Medium Links (Add CONTAINS to the mix)
621+
path_records, _ = db.cypher_query(
622+
"""
623+
MATCH (BaseStandard:NeoStandard {name: $name1})
624+
MATCH (CompareStandard:NeoStandard {name: $name2})
625+
MATCH p = allShortestPaths((BaseStandard)-[:(LINKED_TO|AUTOMATICALLY_LINKED_TO|SAME|CONTAINS)*..20]-(CompareStandard))
626+
WITH p
627+
WHERE length(p) > 1 AND ALL(n in NODES(p) WHERE (n:NeoCRE or n = BaseStandard or n = CompareStandard) AND NOT n.name in $denylist)
628+
RETURN p
629+
""",
630+
{"name1": name_1, "name2": name_2, "denylist": denylist},
631+
resolve_objects=True,
632+
)
633+
634+
if path_records and len(path_records) > 0:
635+
logger.info(
636+
f"Gap Analysis: Tier 2 (Medium) found {len(path_records)} paths. Pruning remainder."
637+
)
638+
return self._format_gap_analysis_response(base_standard, path_records)
639+
640+
# Tier 3: Weak/All Links (Wildcard - The original expensive query)
641+
logger.info(
642+
"Gap Analysis: Tiers 1 & 2 empty. Executing Tier 3 (Wildcard search)."
643+
)
644+
path_records_all, _ = db.cypher_query(
645+
"""
646+
MATCH (BaseStandard:NeoStandard {name: $name1})
647+
MATCH (CompareStandard:NeoStandard {name: $name2})
648+
MATCH p = allShortestPaths((BaseStandard)-[*..20]-(CompareStandard))
649+
WITH p
650+
WHERE length(p) > 1 AND ALL (n in NODES(p) where (n:NeoCRE or n = BaseStandard or n = CompareStandard) AND NOT n.name in $denylist)
651+
RETURN p
652+
""",
653+
{"name1": name_1, "name2": name_2, "denylist": denylist},
654+
resolve_objects=True,
655+
)
656+
657+
return self._format_gap_analysis_response(base_standard, path_records_all)
658+
659+
@classmethod
660+
def _gap_analysis_original(self, name_1, name_2):
661+
"""
662+
ORIGINAL: Exhaustive traversal (always runs both queries)
663+
664+
This is the safe default - maintains backward compatibility.
665+
"""
666+
logger.info(
667+
f"Performing ORIGINAL GraphDB queries for gap analysis {name_1}>>{name_2}"
668+
)
669+
base_standard = NeoStandard.nodes.filter(name=name_1)
670+
denylist = ["Cross-cutting concerns"]
671+
from datetime import datetime
672+
673+
# Query 1: Wildcard (all relationships)
674+
path_records_all, _ = db.cypher_query(
675+
"""
676+
MATCH (BaseStandard:NeoStandard {name: $name1})
677+
MATCH (CompareStandard:NeoStandard {name: $name2})
678+
MATCH p = allShortestPaths((BaseStandard)-[*..20]-(CompareStandard))
679+
WITH p
680+
WHERE length(p) > 1 AND ALL (n in NODES(p) where (n:NeoCRE or n = BaseStandard or n = CompareStandard) AND NOT n.name in $denylist)
681+
RETURN p
682+
""",
683+
{"name1": name_1, "name2": name_2, "denylist": denylist},
684+
resolve_objects=True,
685+
)
686+
687+
# Query 2: Filtered (LINKED_TO, AUTOMATICALLY_LINKED_TO, CONTAINS)
688+
path_records, _ = db.cypher_query(
689+
"""
690+
MATCH (BaseStandard:NeoStandard {name: $name1})
691+
MATCH (CompareStandard:NeoStandard {name: $name2})
692+
MATCH p = allShortestPaths((BaseStandard)-[:(LINKED_TO|AUTOMATICALLY_LINKED_TO|CONTAINS)*..20]-(CompareStandard))
693+
WITH p
694+
WHERE length(p) > 1 AND ALL(n in NODES(p) WHERE (n:NeoCRE or n = BaseStandard or n = CompareStandard) AND NOT n.name in $denylist)
695+
RETURN p
696+
""",
697+
{"name1": name_1, "name2": name_2, "denylist": denylist},
698+
resolve_objects=True,
699+
)
700+
701+
# Combine results (original behavior)
702+
def format_segment(seg: StructuredRel, nodes):
703+
relation_map = {
704+
RelatedRel: "RELATED",
705+
ContainsRel: "CONTAINS",
706+
LinkedToRel: "LINKED_TO",
707+
AutoLinkedToRel: "AUTOMATICALLY_LINKED_TO",
708+
}
709+
start_node = [
710+
node for node in nodes if node.element_id == seg._start_node_element_id
711+
][0]
712+
end_node = [
713+
node for node in nodes if node.element_id == seg._end_node_element_id
714+
][0]
715+
716+
return {
717+
"start": NEO_DB.parse_node_no_links(start_node),
718+
"end": NEO_DB.parse_node_no_links(end_node),
719+
"relationship": relation_map[type(seg)],
720+
}
721+
722+
def format_path_record(rec):
723+
return {
724+
"start": NEO_DB.parse_node_no_links(rec.start_node),
725+
"end": NEO_DB.parse_node_no_links(rec.end_node),
726+
"path": [format_segment(seg, rec.nodes) for seg in rec.relationships],
727+
}
728+
729+
return [NEO_DB.parse_node_no_links(rec) for rec in base_standard], [
730+
format_path_record(rec[0]) for rec in (path_records + path_records_all)
731+
]
732+
564733
def gap_analysis(self, name_1, name_2):
565734
logger.info(f"Performing GraphDB queries for gap analysis {name_1}>>{name_2}")
566735
base_standard = NeoStandard.nodes.filter(name=name_1)

application/frontend/src/pages/GapAnalysis/GapAnalysis.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,14 @@ function useQuery() {
4444
const GetStrength = (score) => {
4545
if (score == 0) return 'Direct';
4646
if (score <= GA_STRONG_UPPER_LIMIT) return 'Strong';
47-
if (score >= 20) return 'Weak';
47+
if (score >= 7) return 'Weak';
4848
return 'Average';
4949
};
5050

5151
const GetStrengthColor = (score) => {
5252
if (score === 0) return 'darkgreen';
5353
if (score <= GA_STRONG_UPPER_LIMIT) return '#93C54B';
54-
if (score >= 20) return 'Red';
54+
if (score >= 7) return 'Red';
5555
return 'Orange';
5656
};
5757

@@ -102,7 +102,7 @@ const GetResultLine = (path, gapAnalysis, key) => {
102102
<b style={{ color: GetStrengthColor(6) }}>{GetStrength(6)}</b>: Connected likely to have partial
103103
overlap
104104
<br />
105-
<b style={{ color: GetStrengthColor(22) }}>{GetStrength(22)}</b>: Weakly connected likely to have
105+
<b style={{ color: GetStrengthColor(7) }}>{GetStrength(7)}</b>: Weakly connected likely to have
106106
small or no overlap
107107
</Popup.Content>
108108
</Popup>

0 commit comments

Comments
 (0)