-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
11 changed files
with
467 additions
and
290 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,100 +1,2 @@ | ||
import dataclasses | ||
import typing | ||
|
||
import numpy as np | ||
|
||
from ..entities.card import BaseCard | ||
from ..entities.model import EstiModel | ||
|
||
|
||
@dataclasses.dataclass(init=False) | ||
class Problem: | ||
description: str | ||
affected_cards_names: list[str] | ||
tags: set[str] | ||
|
||
def __init__(self): | ||
self.description = "" | ||
self.affected_cards_names = [] | ||
self.tags = frozenset() | ||
|
||
def add_tag(self, tag): | ||
self.tags = frozenset(self.tags.union([tag])) | ||
|
||
|
||
class ProblemDetector: | ||
POINT_THRESHOLD = 0.4 | ||
|
||
def __init__(self, model: EstiModel, cards: typing.Iterable[BaseCard]): | ||
self.model = model | ||
self.cards = cards | ||
self.problems = [] | ||
|
||
self._get_problems() | ||
|
||
def _get_problems(self): | ||
for card in self.cards: | ||
self._get_card_problem(card) | ||
|
||
def _get_card_problem(self, card): | ||
self._analyze_inconsistent_record(card) | ||
|
||
def _numbers_differ_significantly(self, lhs, rhs): | ||
if np.abs(lhs - rhs) > self.POINT_THRESHOLD: | ||
return True | ||
|
||
def _create_and_add_card_problem(self, card): | ||
problem = Problem() | ||
problem.affected_cards_names.append(card.name) | ||
self.problems.append(problem) | ||
return problem | ||
|
||
def _card_is_not_estimated_but_has_children(self, card): | ||
return card.children and card.point_cost == 0 | ||
|
||
def _treat_inconsistent_estimate(self, card, computed_nominal_cost, recorded_cost, expected_computed_cost): | ||
problem = self._create_and_add_card_problem(card) | ||
problem.add_tag("inconsistent_estimate") | ||
if computed_nominal_cost == 0: | ||
self._inconsistent_card_missing_estimates(problem, card, recorded_cost) | ||
else: | ||
self._inconsistent_card_differing_estimate(problem, card, recorded_cost, expected_computed_cost) | ||
|
||
def _card_has_no_children_with_children(self, card): | ||
for child in card.children: | ||
if child.children: | ||
return False | ||
return True | ||
|
||
def _inconsistent_card_missing_estimates(self, problem, card, recorded_cost): | ||
problem.description = ( | ||
f"'{card.name}' has inconsistent recorded point cost of {recorded_cost:.2g}, " | ||
f"as its children appear to lack estimations." | ||
) | ||
problem.add_tag("missing_estimates") | ||
if self._card_has_no_children_with_children(card): | ||
problem.add_tag("childless_children") | ||
|
||
def _inconsistent_card_differing_estimate(self, problem, card, recorded_cost, expected_computed_cost): | ||
if expected_computed_cost < recorded_cost: | ||
problem.add_tag("sum_of_children_lower") | ||
problem.description = ( | ||
f"'{card.name}' has inconsistent recorded point cost of {recorded_cost:.2g}, " | ||
f"while the deduced cost is {expected_computed_cost:.2g}" | ||
) | ||
|
||
def _analyze_inconsistent_record(self, card): | ||
recorded_cost = card.point_cost | ||
computed_remaining_cost = self.model.remaining_point_estimate_of(card.name).expected | ||
computed_nominal_cost = self.model.nominal_point_estimate_of(card.name).expected | ||
expected_computed_cost = computed_nominal_cost | ||
if card.children: | ||
expected_computed_cost = computed_remaining_cost | ||
|
||
if self._card_is_not_estimated_but_has_children(card): | ||
return | ||
|
||
if not self._numbers_differ_significantly(recorded_cost, expected_computed_cost): | ||
return | ||
|
||
self._treat_inconsistent_estimate(card, computed_nominal_cost, recorded_cost, expected_computed_cost) | ||
from .problem import Problem, ProblemDetector | ||
from .groups import ProblemClassifier |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,109 @@ | ||
import collections | ||
import typing | ||
|
||
from .problem import Problem, get_problem | ||
from . import solutions | ||
|
||
|
||
class ProblemCategory: | ||
name: str = "generic" | ||
description: str = "" | ||
solution: solutions.Solution = None | ||
|
||
required_tags: typing.FrozenSet[str] = frozenset() | ||
unwanted_tags: typing.FrozenSet[str] = frozenset() | ||
|
||
def matches(self, p: Problem): | ||
if p.tags.intersection(self.required_tags) != self.required_tags: | ||
return False | ||
if p.tags.intersection(self.unwanted_tags): | ||
return False | ||
return True | ||
|
||
def _get_solution_of(self, p: Problem): | ||
return self.solution(p) | ||
|
||
def get_solution_of(self, p: Problem): | ||
if not self.solution: | ||
return None | ||
return self._get_solution_of(p) | ||
|
||
|
||
class ProblemClassifier: | ||
CATEGORIES: typing.Mapping[str, ProblemCategory] = dict() | ||
classified_problems: typing.Mapping[str, typing.List[Problem]] | ||
_problems = typing.Mapping[Problem, str] | ||
|
||
def __init__(self): | ||
self.not_classified = [] | ||
self.CATEGORIES = dict(** self.CATEGORIES) | ||
self.classified_problems = collections.defaultdict(list) | ||
self._problems = dict() | ||
|
||
def classify(self, problems: typing.Iterable[Problem]): | ||
for p in problems: | ||
self._classify_problem(p) | ||
|
||
def _classify_problem(self, problem: Problem): | ||
for c_name, c in self.CATEGORIES.items(): | ||
if c.matches(problem): | ||
self.classified_problems[c_name].append(problem) | ||
self._problems[problem] = c_name | ||
return | ||
self.not_classified.append(problem) | ||
|
||
def get_category_of(self, problem: Problem): | ||
cat_name = self._problems.get(problem, None) | ||
return self.CATEGORIES.get(cat_name, ProblemCategory()) | ||
|
||
def get_categories_with_problems(self): | ||
return [self.CATEGORIES[name] for name in self.classified_problems] | ||
|
||
def add_category(self, cat_type: typing.Type[ProblemCategory]): | ||
if (name := cat_type.name) in self.CATEGORIES: | ||
msg = f"Already have a category named '{name}'" | ||
raise KeyError(msg) | ||
self.CATEGORIES[name] = cat_type() | ||
|
||
|
||
def problem_category(cls): | ||
ProblemClassifier.CATEGORIES[cls.name] = cls() | ||
return cls | ||
|
||
|
||
@problem_category | ||
class ReasonableOutdated(ProblemCategory): | ||
name = "reasonable_outdated" | ||
description = "Estimate is inconsistent with children, but lower than the nominal size and greater than the size of tasks not yet completed." | ||
solution = solutions.SolutionByUpdatingSelf | ||
|
||
required_tags = frozenset([ | ||
"inconsistent_estimate", | ||
"estimate_within_nominal", | ||
"sum_of_children_lower", | ||
]) | ||
unwanted_tags = frozenset([ | ||
"missing_estimates", | ||
]) | ||
|
||
|
||
@problem_category | ||
class GenericInconsistent(ProblemCategory): | ||
name = "generic_inconsistent" | ||
solution = solutions.SolutionByUpdatingSelf | ||
|
||
required_tags = frozenset(["inconsistent_estimate"]) | ||
unwanted_tags = frozenset(["missing_estimates"]) | ||
|
||
|
||
@problem_category | ||
class UnestimatedChildren(ProblemCategory): | ||
name = "unestimated_children" | ||
description = "Children have no size estimated, but the parent issue has." | ||
solution = solutions.SolutionByUpdatingChildren | ||
|
||
required_tags = set([ | ||
"inconsistent_estimate", | ||
"missing_estimates", | ||
"childless_children", | ||
]) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,129 @@ | ||
import dataclasses | ||
import typing | ||
import collections | ||
|
||
import numpy as np | ||
|
||
from ..entities.card import BaseCard | ||
from ..entities.model import EstiModel | ||
|
||
|
||
TAG_TO_PROBLEM_TYPE = dict() | ||
|
||
def get_problem(** data): | ||
data["tags"] = frozenset(data.get("tags", frozenset())) | ||
problem_tags = list(data["tags"].intersection(set(TAG_TO_PROBLEM_TYPE.keys()))) | ||
if not problem_tags: | ||
return Problem(** data) | ||
return TAG_TO_PROBLEM_TYPE[problem_tags[0]](** data) | ||
|
||
|
||
def problem_associated_with_tag(tag): | ||
def wrapper(wrapped): | ||
TAG_TO_PROBLEM_TYPE[tag] = wrapped | ||
return wrapped | ||
return wrapper | ||
|
||
|
||
@dataclasses.dataclass(frozen=True) | ||
class Problem: | ||
description: str = "" | ||
affected_card_name: str = "" | ||
tags: typing.FrozenSet[str] = frozenset() | ||
|
||
def format_task_name(self): | ||
return self.affected_card_name | ||
|
||
|
||
@problem_associated_with_tag("inconsistent_estimate") | ||
@dataclasses.dataclass(frozen=True) | ||
class ValueProblem(Problem): | ||
value_expected: float = None | ||
value_found: float = None | ||
|
||
|
||
class ProblemDetector: | ||
POINT_THRESHOLD = 0.4 | ||
|
||
def __init__(self, model: EstiModel, cards: typing.Iterable[BaseCard]): | ||
self.model = model | ||
self.cards = cards | ||
self.problems = [] | ||
|
||
self._get_problems() | ||
|
||
def _get_problems(self): | ||
for card in self.cards: | ||
self._get_card_problem(card) | ||
|
||
def _get_card_problem(self, card): | ||
self._analyze_inconsistent_record(card) | ||
|
||
def _numbers_differ_significantly(self, lhs, rhs): | ||
if np.abs(lhs - rhs) > self.POINT_THRESHOLD: | ||
return True | ||
|
||
def _create_card_problem_data(self, card): | ||
data = dict() | ||
data["affected_card_name"] = card.name | ||
data["tags"] = set() | ||
return data | ||
|
||
def _card_is_not_estimated_but_has_children(self, card): | ||
return card.children and card.point_cost == 0 | ||
|
||
def _treat_inconsistent_estimate(self, card, computed_nominal_cost, recorded_cost, expected_computed_cost): | ||
data = self._create_card_problem_data(card) | ||
data["tags"].add("inconsistent_estimate") | ||
data["value_found"] = recorded_cost | ||
if computed_nominal_cost == 0: | ||
self._inconsistent_card_missing_children_estimates(data, card) | ||
else: | ||
self._inconsistent_card_differing_estimate(data, card, expected_computed_cost, computed_nominal_cost) | ||
data["tags"] = frozenset(data["tags"]) | ||
self.problems.append(get_problem(** data)) | ||
|
||
def _card_has_no_children_with_children(self, card): | ||
for child in card.children: | ||
if child.children: | ||
return False | ||
return True | ||
|
||
def _inconsistent_card_missing_children_estimates(self, data, card): | ||
recorded_cost = data['value_found'] | ||
data["description"] = ( | ||
f"'{card.name}' has inconsistent recorded point cost of {recorded_cost:.2g}, " | ||
f"as its children appear to lack estimations." | ||
) | ||
data["tags"].add("missing_children_estimates") | ||
if self._card_has_no_children_with_children(card): | ||
data["tags"].add("has_only_childless_children") | ||
|
||
def _inconsistent_card_differing_estimate(self, data, card, expected_computed_cost, computed_nominal_cost): | ||
recorded_cost = data['value_found'] | ||
if expected_computed_cost < recorded_cost: | ||
data["tags"].add("sum_of_children_lower") | ||
if recorded_cost <= computed_nominal_cost: | ||
data["tags"].add("estimate_within_nominal") | ||
data["description"] = ( | ||
f"'{card.name}' has inconsistent recorded point cost of {recorded_cost:.2g}, " | ||
f"while the deduced cost is {expected_computed_cost:.2g}" | ||
) | ||
data["value_expected"] = expected_computed_cost | ||
|
||
def _analyze_inconsistent_record(self, card): | ||
recorded_cost = card.point_cost | ||
computed_remaining_cost = self.model.remaining_point_estimate_of(card.name).expected | ||
computed_nominal_cost = self.model.nominal_point_estimate_of(card.name).expected | ||
expected_computed_cost = computed_nominal_cost | ||
if card.children: | ||
expected_computed_cost = computed_remaining_cost | ||
|
||
if self._card_is_not_estimated_but_has_children(card): | ||
return | ||
|
||
if not self._numbers_differ_significantly(recorded_cost, expected_computed_cost): | ||
return | ||
|
||
self._treat_inconsistent_estimate(card, computed_nominal_cost, recorded_cost, expected_computed_cost) | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
import typing | ||
|
||
from ..entities.card import BaseCard | ||
from .problem import Problem | ||
|
||
|
||
class Solution: | ||
card_name: str | ||
description: str = "" | ||
|
||
def __init__(self, problem: Problem): | ||
self.problem = problem | ||
|
||
def describe(self): | ||
return "" | ||
|
||
|
||
class SolutionByUpdating(Solution): | ||
updates_model: bool | ||
|
||
def __init__(self, problem: Problem): | ||
super().__init__(problem) | ||
self.updates_model = True | ||
|
||
def describe(self): | ||
return f"Update the record of '{self.card_name}'" | ||
|
||
|
||
class SolutionByUpdatingChildren(SolutionByUpdating): | ||
action = "update_children_points" | ||
description = "Update children of respective card, so the subtree is consistent" | ||
|
||
def describe(self): | ||
return f"Update children of '{self.card_name}', so they become consistent with the its record." | ||
|
||
|
||
class SolutionByUpdatingSelf(SolutionByUpdating): | ||
action = "update_points" | ||
description = "Update the respective card, so it is consistent with its children" | ||
value: float | ||
|
||
def __init__(self, problem: Problem): | ||
super().__init__(problem) | ||
self.updates_model = False | ||
|
||
def describe(self): | ||
return f"Update the record of '{self.card_name}', so it matches records of its children." |
Oops, something went wrong.